diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index aa0a8c22..8bc06f10 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1700,6 +1700,47 @@ ], "type": "object" }, + "CommandInvokeParams": { + "properties": { + "command_id": { + "description": "Opaque endpoint-issued command identifier from the client-shell projection.", + "type": "string" + }, + "pane_id": { + "type": [ + "string", + "null" + ] + }, + "selection": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/PaneSelectionReadParams" + }, + { + "type": "null" + } + ], + "description": "Client-owned selection coordinates, validated against the pane's content revision." + }, + "tab_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "command_id" + ], + "type": "object" + }, "EmptyParams": { "type": "object" }, @@ -2403,6 +2444,94 @@ ], "type": "object" }, + "PaneCopyMotion": { + "enum": [ + "line_end", + "first_non_blank", + "next_word_start", + "previous_word_start", + "next_word_end", + "next_big_word_start", + "previous_big_word_start", + "next_big_word_end", + "previous_paragraph", + "next_paragraph" + ], + "type": "string" + }, + "PaneCopyMotionParams": { + "properties": { + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cursor": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + }, + "motion": { + "$ref": "#/schemas/request/$defs/PaneCopyMotion" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "cursor", + "motion" + ], + "type": "object" + }, + "PaneCopySearchDirection": { + "enum": [ + "forward", + "backward" + ], + "type": "string" + }, + "PaneCopySearchParams": { + "properties": { + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "cursor": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + }, + "direction": { + "$ref": "#/schemas/request/$defs/PaneCopySearchDirection" + }, + "pane_id": { + "type": "string" + }, + "previous": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/PaneTextRange" + }, + { + "type": "null" + } + ] + }, + "query": { + "type": "string" + } + }, + "required": [ + "pane_id", + "query", + "direction", + "cursor", + "content_revision" + ], + "type": "object" + }, "PaneCurrentParams": { "properties": { "caller_pane_id": { @@ -2581,6 +2710,47 @@ }, "type": "object" }, + "PaneLinkActivateParams": { + "properties": { + "col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "viewport_row": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "pane_id", + "viewport_row", + "col" + ], + "type": "object" + }, "PaneListParams": { "properties": { "workspace_id": { @@ -3011,6 +3181,50 @@ ], "type": "string" }, + "PaneScrollParams": { + "properties": { + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "offset_from_bottom" + ], + "type": "object" + }, + "PaneSelectionReadParams": { + "properties": { + "anchor": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + }, + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cursor": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "anchor", + "cursor" + ], + "type": "object" + }, "PaneSendInputParams": { "properties": { "keys": { @@ -3158,6 +3372,41 @@ ], "type": "object" }, + "PaneTextPoint": { + "properties": { + "col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "row": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "PaneTextRange": { + "properties": { + "end": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + }, + "start": { + "$ref": "#/schemas/request/$defs/PaneTextPoint" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, "PaneWaitForOutputParams": { "properties": { "lines": { @@ -3631,6 +3880,21 @@ } ] }, + "ProductAnnouncementDismissParams": { + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "version", + "id" + ], + "type": "object" + }, "ReadFormat": { "enum": [ "text", @@ -3647,6 +3911,17 @@ ], "type": "string" }, + "ReleaseNotesDismissParams": { + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "type": "object" + }, "ServerLiveHandoffParams": { "properties": { "expected_protocol": { @@ -4182,6 +4457,13 @@ "string", "null" ] + }, + "source_workspace_id": { + "description": "Workspace whose focused pane supplies the `follow` cwd policy.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -4565,6 +4847,54 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "product_announcement.dismiss", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/ProductAnnouncementDismissParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "release_notes.dismiss", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/ReleaseNotesDismissParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "command.invoke", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/CommandInvokeParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -5333,6 +5663,86 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.scroll", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneScrollParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.edit_scrollback", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.selection.read", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneSelectionReadParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.copy_motion", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneCopyMotionParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "pane.copy_search", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneCopySearchParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -5413,6 +5823,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.link.activate", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneLinkActivateParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -5701,6 +6127,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "integration.list", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -7171,6 +7613,33 @@ ], "type": "object" }, + "IntegrationInfo": { + "properties": { + "available": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "label": { + "type": "string" + }, + "state": { + "$ref": "#/schemas/success_response/$defs/IntegrationState" + }, + "target": { + "$ref": "#/schemas/success_response/$defs/IntegrationTarget" + } + }, + "required": [ + "target", + "label", + "command", + "available", + "state" + ], + "type": "object" + }, "IntegrationInstallResult": { "properties": { "messages": { @@ -7185,6 +7654,14 @@ ], "type": "object" }, + "IntegrationState": { + "enum": [ + "not_installed", + "current", + "outdated" + ], + "type": "string" + }, "IntegrationTarget": { "enum": [ "pi", @@ -8012,6 +8489,41 @@ ], "type": "object" }, + "PaneTextPoint": { + "properties": { + "col": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "row": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "row", + "col" + ], + "type": "object" + }, + "PaneTextRange": { + "properties": { + "end": { + "$ref": "#/schemas/success_response/$defs/PaneTextPoint" + }, + "start": { + "$ref": "#/schemas/success_response/$defs/PaneTextPoint" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, "PaneZoomReason": { "enum": [ "single_pane", @@ -9280,6 +9792,103 @@ ], "type": "object" }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "const": "pane_selection", + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "text" + ], + "type": "object" + }, + { + "properties": { + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "cursor": { + "$ref": "#/schemas/success_response/$defs/PaneTextPoint" + }, + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane_copy_motion", + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "cursor", + "content_revision" + ], + "type": "object" + }, + { + "properties": { + "content_revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "current": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "current_global": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "matches": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneTextRange" + }, + "type": "array" + }, + "pane_id": { + "type": "string" + }, + "total": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "pane_copy_search", + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "content_revision", + "matches", + "total" + ], + "type": "object" + }, { "properties": { "revision": { @@ -9496,6 +10105,25 @@ ], "type": "object" }, + { + "properties": { + "integrations": { + "items": { + "$ref": "#/schemas/success_response/$defs/IntegrationInfo" + }, + "type": "array" + }, + "type": { + "const": "integration_list", + "type": "string" + } + }, + "required": [ + "type", + "integrations" + ], + "type": "object" + }, { "properties": { "details": { @@ -9718,6 +10346,28 @@ ], "type": "object" }, + { + "properties": { + "handled": { + "type": "boolean" + }, + "type": { + "const": "pane_link_activated", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "handled" + ], + "type": "object" + }, { "properties": { "logs": { diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 189be5b0..85fb3dad 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -15,7 +15,6 @@ herdr --session work # launch or attach to a named session herdr --remote workbox # attach through SSH, using local keybindings herdr --remote workbox --remote-keybindings server herdr --remote workbox --handoff -herdr --no-session # single-process escape hatch herdr --default-config # print default config herdr update # download and install from the configured channel herdr update --handoff # opt into live handoff for supported running servers diff --git a/docs/next/website/src/content/docs/how-to-work.mdx b/docs/next/website/src/content/docs/how-to-work.mdx index 8d3ad081..6e74e59d 100644 --- a/docs/next/website/src/content/docs/how-to-work.mdx +++ b/docs/next/website/src/content/docs/how-to-work.mdx @@ -100,4 +100,4 @@ herdr --remote workbox Use `herdr` for local work. Use `ssh you@server` then `herdr` when you want Herdr to behave like tmux on that remote shell or when you are using a phone SSH client. Use `herdr --remote ` when you want a local thin client for a remote session, including local clipboard image paste bridging. -For remote bootstrap details, named remote sessions, custom binaries, direct terminal attach, and `--no-session`, see [Persistence and remote access](/docs/persistence-remote/). +For remote bootstrap details, named remote sessions, custom binaries, and direct terminal attach, see [Persistence and remote access](/docs/persistence-remote/). 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 0b3e06f3..d443f317 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -15,7 +15,6 @@ herdr --session work # 名前付きセッションを起動またはア herdr --remote workbox # SSH 越しにアタッチ (ローカルのキーバインドを使用) herdr --remote workbox --remote-keybindings server herdr --remote workbox --handoff -herdr --no-session # シングルプロセスの逃げ道 herdr --default-config # デフォルト設定を表示 herdr update # 設定済みチャンネルからダウンロードしてインストール herdr update --handoff # 対応する実行中サーバーでライブハンドオフにオプトイン diff --git a/docs/next/website/src/content/docs/ja/how-to-work.mdx b/docs/next/website/src/content/docs/ja/how-to-work.mdx index 0365df0b..d6d6bd95 100644 --- a/docs/next/website/src/content/docs/ja/how-to-work.mdx +++ b/docs/next/website/src/content/docs/ja/how-to-work.mdx @@ -100,4 +100,4 @@ herdr --remote workbox ローカル作業には `herdr` を使います。リモートシェル上で Herdr を tmux のように使いたいときや、スマートフォンの SSH クライアントを使っているときは `ssh you@server` してから `herdr` を使います。リモートセッション用のローカルシンクライアントが欲しいとき (ローカルクリップボードの画像貼り付けブリッジを含む) は `herdr --remote ` を使います。 -リモートブートストラップの詳細、名前付きリモートセッション、カスタムバイナリ、ダイレクトターミナルアタッチ、`--no-session` については[永続化とリモートアクセス](/ja/docs/persistence-remote/)を参照してください。 +リモートブートストラップの詳細、名前付きリモートセッション、カスタムバイナリ、ダイレクトターミナルアタッチについては[永続化とリモートアクセス](/ja/docs/persistence-remote/)を参照してください。 diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx index 266d407b..23c6e817 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -118,13 +118,3 @@ herdr terminal attach term_abc123 ```bash herdr terminal attach term_abc123 --takeover ``` - -## シングルプロセスの逃げ道 - -バックグラウンドのサーバー/クライアント分離なしで Herdr を実行するには `--no-session` を使います: - -```bash -herdr --no-session -``` - -これは主にデバッグや互換性のための逃げ道です。デフォルトの永続セッションモードが通常の使い方です。 diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index 5044c66d..bb8404c8 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -151,13 +151,3 @@ newline-delimited JSON commands on stdin. `terminal.input` sends text or base64 bytes, `terminal.resize` changes the controller viewport, `terminal.scroll` scrolls the attached viewport, and `terminal.release` closes the controller. Only one controller owns input and resize at a time. - -## Single-process escape hatch - -Use `--no-session` to run Herdr without the background server/client split: - -```bash -herdr --no-session -``` - -Use `--no-session` mainly for debugging or compatibility. Persistent session mode remains the default. diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 8a055a36..4d4559c1 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -191,8 +191,8 @@ layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or `bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache one image transaction per render pass, so arbitrary layer sets progress without -an aggregate frame. Headless transport keeps each transaction within its 32 MiB -wire limit; local monolithic rendering does not apply that transport limit. +an aggregate frame. Client transport keeps each transaction within its 32 MiB +wire limit. ```json {"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} @@ -219,8 +219,8 @@ inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal accepts the file, or after a safe owned inline fallback is installed. Confirmed file-transport failure disables direct files for that client connection without disabling exact pixel mouse. A timeout or client loss closes the stream without -acknowledging source reuse. Monolithic `--no-session` mode advertises neither -fast file transport nor exact pixel mouse and remains on owned inline fallback. +acknowledging source reuse. Clients that cannot negotiate direct file transport +remain on the owned inline fallback. Direct files are always complete canonical `width * height * 4` RGBA frames. `file_frame_max_bytes` is the limit that remains eligible for owned inline fallback. 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 b00ad592..38a7a4f2 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 @@ -15,7 +15,6 @@ herdr --session work # 启动或连接命名会话 herdr --remote workbox # 通过 SSH 连接,使用本地按键绑定 herdr --remote workbox --remote-keybindings server herdr --remote workbox --handoff -herdr --no-session # 单进程逃生舱 herdr --default-config # 打印默认配置 herdr update # 从配置的通道下载并安装 herdr update --handoff # 对受支持的运行中服务器启用实时交接 diff --git a/docs/next/website/src/content/docs/zh-cn/how-to-work.mdx b/docs/next/website/src/content/docs/zh-cn/how-to-work.mdx index 0df1b87f..5f455a66 100644 --- a/docs/next/website/src/content/docs/zh-cn/how-to-work.mdx +++ b/docs/next/website/src/content/docs/zh-cn/how-to-work.mdx @@ -100,4 +100,4 @@ herdr --remote workbox 本地工作用 `herdr`。想让 Herdr 在远程 shell 上表现得像 tmux,或者在用手机 SSH 客户端时,用 `ssh you@server` 再 `herdr`。想要一个连接远程会话的本地瘦客户端 (包括本地剪贴板图像粘贴桥接) 时,用 `herdr --remote `。 -关于远程引导细节、命名远程会话、自定义二进制、直接终端附加和 `--no-session`,参见[持久化与远程访问](/zh-cn/docs/persistence-remote/)。 +关于远程引导细节、命名远程会话、自定义二进制和直接终端附加,参见[持久化与远程访问](/zh-cn/docs/persistence-remote/)。 diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx index ba50130e..3f142547 100644 --- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -118,13 +118,3 @@ herdr terminal attach term_abc123 ```bash herdr terminal attach term_abc123 --takeover ``` - -## 单进程逃生舱 - -用 `--no-session` 在没有后台服务器/客户端分离的情况下运行 Herdr: - -```bash -herdr --no-session -``` - -这主要是调试或兼容性的逃生舱。默认的持久会话模式才是常规路径。 diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 475c6a07..db22daf1 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -575,7 +575,7 @@ "key": "keys.detach", "type": "keybinding", "default": "\"prefix+q\"", - "description": "Detach from server/client mode, or exit --no-session mode." + "description": "Detach the current client from its Herdr server." }, { "key": "keys.reload_config", diff --git a/scripts/windows_check.ps1 b/scripts/windows_check.ps1 index 1907d66b..2811c6a2 100644 --- a/scripts/windows_check.ps1 +++ b/scripts/windows_check.ps1 @@ -96,5 +96,7 @@ if ($Mode -eq "lint") { Invoke-CargoTestFilter "windows_" Invoke-CargoTestFilter "server::client_transport::tests" -Invoke-CargoTestFilter "app::tests::native_repeats_and_releases_follow_the_pressed_pane" -Exact +Invoke-CargoTestFilter "input::lease::tests::duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases" -Exact +Invoke-CargoTestFilter "client::shell::tests::input_domain::physical_release_uses_the_leased_press_code_with_current_modifiers" -Exact +Invoke-CargoTestFilter "client::shell::tests::input_domain::pane_key_release_keeps_the_press_target" -Exact Invoke-Checked cargo @("build", "--locked", "--target", "x86_64-pc-windows-msvc") diff --git a/src/api/mod.rs b/src/api/mod.rs index b4a5d262..363d7702 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -8,7 +8,7 @@ mod wait; pub use event_hub::EventHub; pub(crate) use server::start_server_with_stop_control; -pub use server::{start_server_with_capabilities, ServerHandle}; +pub use server::ServerHandle; pub use status::{read_runtime_status_at, RuntimeStatus}; use std::path::PathBuf; @@ -25,6 +25,9 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { Method::ServerReloadConfig(_) | Method::ServerReloadAgentManifests(_) | Method::NotificationShow(_) + | Method::ProductAnnouncementDismiss(_) + | Method::ReleaseNotesDismiss(_) + | Method::CommandInvoke(_) | Method::WorkspaceCreate(_) | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) @@ -55,6 +58,8 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneZoom(_) | Method::PaneFocusDirection(_) | Method::PaneResize(_) + | Method::PaneScroll(_) + | Method::PaneEditScrollback(_) | Method::PaneFocus(_) | Method::PaneInputSet(_) | Method::PaneRename(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index e06b701c..074cd4ed 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; pub mod agents; +pub mod commands; pub mod common; pub mod events; pub mod integrations; @@ -14,6 +15,7 @@ pub mod workspaces; pub mod worktrees; pub use agents::*; +pub use commands::*; pub use common::*; pub use events::*; pub use integrations::*; @@ -57,6 +59,12 @@ pub enum Method { ServerReloadAgentManifests(EmptyParams), #[serde(rename = "notification.show")] NotificationShow(NotificationShowParams), + #[serde(rename = "product_announcement.dismiss")] + ProductAnnouncementDismiss(ProductAnnouncementDismissParams), + #[serde(rename = "release_notes.dismiss")] + ReleaseNotesDismiss(ReleaseNotesDismissParams), + #[serde(rename = "command.invoke")] + CommandInvoke(CommandInvokeParams), #[serde(rename = "client.window_title.set")] ClientWindowTitleSet(ClientWindowTitleSetParams), #[serde(rename = "client.window_title.clear")] @@ -153,6 +161,16 @@ pub enum Method { PaneFocusDirection(PaneFocusDirectionParams), #[serde(rename = "pane.resize")] PaneResize(PaneResizeParams), + #[serde(rename = "pane.scroll")] + PaneScroll(PaneScrollParams), + #[serde(rename = "pane.edit_scrollback")] + PaneEditScrollback(PaneTarget), + #[serde(rename = "pane.selection.read")] + PaneSelectionRead(PaneSelectionReadParams), + #[serde(rename = "pane.copy_motion")] + PaneCopyMotion(PaneCopyMotionParams), + #[serde(rename = "pane.copy_search")] + PaneCopySearch(PaneCopySearchParams), #[serde(rename = "pane.list")] PaneList(PaneListParams), #[serde(rename = "pane.current")] @@ -163,6 +181,8 @@ pub enum Method { PaneFocus(PaneTarget), #[serde(rename = "pane.input.set")] PaneInputSet(PaneInputSetParams), + #[serde(rename = "pane.link.activate")] + PaneLinkActivate(PaneLinkActivateParams), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] @@ -214,6 +234,8 @@ pub enum Method { EventsWait(EventsWaitParams), #[serde(rename = "pane.wait_for_output")] PaneWaitForOutput(PaneWaitForOutputParams), + #[serde(rename = "integration.list")] + IntegrationList(EmptyParams), #[serde(rename = "integration.install")] IntegrationInstall(IntegrationInstallParams), #[serde(rename = "integration.uninstall")] diff --git a/src/api/schema/commands.rs b/src/api/schema/commands.rs new file mode 100644 index 00000000..c9ccd4bc --- /dev/null +++ b/src/api/schema/commands.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct CommandInvokeParams { + /// Opaque endpoint-issued command identifier from the client-shell projection. + pub command_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, + /// Client-owned selection coordinates, validated against the pane's content revision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, +} diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 7313002c..15f56e6c 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -35,6 +35,17 @@ pub struct PaneTarget { pub pane_id: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ProductAnnouncementDismissParams { + pub version: String, + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ReleaseNotesDismissParams { + pub version: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TabTarget { pub tab_id: String, @@ -109,14 +120,6 @@ impl NotificationShowSound { pub fn is_none(&self) -> bool { matches!(self, Self::None) } - - pub fn to_sound(self) -> Option { - match self { - Self::None => None, - Self::Done => Some(crate::sound::Sound::Done), - Self::Request => Some(crate::sound::Sound::Request), - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 6151dedb..0fce2913 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -1,5 +1,22 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum IntegrationState { + NotInstalled, + Current, + Outdated, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct IntegrationInfo { + pub target: IntegrationTarget, + pub label: String, + pub command: String, + pub available: bool, + pub state: IntegrationState, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct IntegrationInstallParams { pub target: IntegrationTarget, diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 6435a422..517870f7 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -48,6 +48,17 @@ pub struct PaneInputSetParams { pub right_click: PaneRightClickTarget, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneLinkActivateParams { + pub pane_id: String, + pub viewport_row: u16, + pub col: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset_from_bottom: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PaneDirection { @@ -231,6 +242,75 @@ pub struct PaneResizeParams { pub amount: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneScrollParams { + pub pane_id: String, + pub offset_from_bottom: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneTextPoint { + pub row: u32, + pub col: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneSelectionReadParams { + pub pane_id: String, + pub anchor: PaneTextPoint, + pub cursor: PaneTextPoint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_revision: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PaneCopyMotion { + LineEnd, + FirstNonBlank, + NextWordStart, + PreviousWordStart, + NextWordEnd, + NextBigWordStart, + PreviousBigWordStart, + NextBigWordEnd, + PreviousParagraph, + NextParagraph, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneCopyMotionParams { + pub pane_id: String, + pub cursor: PaneTextPoint, + pub motion: PaneCopyMotion, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_revision: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PaneCopySearchDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneTextRange { + pub start: PaneTextPoint, + pub end: PaneTextPoint, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneCopySearchParams { + pub pane_id: String, + pub query: String, + pub direction: PaneCopySearchDirection, + pub cursor: PaneTextPoint, + pub content_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] pub struct PaneListParams { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index da2813a6..180d6645 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -9,7 +9,7 @@ use super::integrations::{ use super::panes::{ LayoutDescription, PaneEdgesResult, PaneFocusDirectionResult, PaneInfo, PaneLayoutSnapshot, PaneMoveResult, PaneNeighborResult, PaneProcessInfo, PaneReadResult, PaneResizeResult, - PaneSwapResult, PaneZoomResult, + PaneSwapResult, PaneTextPoint, PaneTextRange, PaneZoomResult, }; use super::plugins::{ InstalledPluginInfo, PluginActionInfo, PluginCommandLogInfo, PluginInvocationContext, @@ -162,6 +162,25 @@ pub enum ResponseResult { PaneRead { read: PaneReadResult, }, + PaneSelection { + pane_id: String, + text: String, + }, + PaneCopyMotion { + pane_id: String, + cursor: PaneTextPoint, + content_revision: u64, + }, + PaneCopySearch { + pane_id: String, + content_revision: u64, + matches: Vec, + total: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + current: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_global: Option, + }, PaneGraphicsFrameAck { sequence: u64, revision: u64, @@ -210,6 +229,9 @@ pub enum ResponseResult { changed: bool, reason: ClientWindowTitleReason, }, + IntegrationList { + integrations: Vec, + }, IntegrationInstall { target: IntegrationTarget, details: IntegrationInstallResult, @@ -252,6 +274,11 @@ pub enum ResponseResult { context: PluginInvocationContext, log: PluginCommandLogInfo, }, + PaneLinkActivated { + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option, + handled: bool, + }, PluginLogList { logs: Vec, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 663f3fc4..e4a7f2a3 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -50,6 +50,7 @@ fn request_uses_dot_method_names() { let request = Request { id: "req_1".into(), method: Method::WorkspaceCreate(WorkspaceCreateParams { + source_workspace_id: None, cwd: Some("/tmp".into()), focus: true, label: Some("api".into()), @@ -272,6 +273,54 @@ fn request_round_trips_for_agent_explain() { assert_eq!(restored, request); } +#[test] +fn integration_list_request_and_response_round_trip() { + let request = Request { + id: "req_integrations".into(), + method: Method::IntegrationList(EmptyParams::default()), + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "integration.list"); + assert_eq!(serde_json::from_value::(json).unwrap(), request); + + let response = SuccessResponse { + id: "req_integrations".into(), + result: ResponseResult::IntegrationList { + integrations: vec![IntegrationInfo { + target: IntegrationTarget::Codex, + label: "codex".into(), + command: "codex".into(), + available: true, + state: IntegrationState::Outdated, + }], + }, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["result"]["type"], "integration_list"); + assert_eq!(json["result"]["integrations"][0]["state"], "outdated"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + response + ); +} + +#[test] +fn command_invoke_request_round_trips_without_command_text() { + let request = Request { + id: "req_command".into(), + method: Method::CommandInvoke(CommandInvokeParams { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + workspace_id: Some("w1".into()), + tab_id: Some("w1:t1".into()), + pane_id: Some("w1:p1".into()), + selection: None, + }), + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "command.invoke"); + assert_eq!(serde_json::from_value::(json).unwrap(), request); +} + #[test] fn notification_show_request_parses() { let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#; @@ -1263,6 +1312,32 @@ fn event_wait_parses_typed_match() { ); } +#[test] +fn pane_link_activate_round_trips() { + let request = Request { + id: "req_pane_link".into(), + method: Method::PaneLinkActivate(PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 3, + col: 7, + content_revision: Some(42), + offset_from_bottom: Some(5), + }), + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["method"], "pane.link.activate"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, request); + + let response = ResponseResult::PaneLinkActivated { + url: Some("https://example.test".into()), + handled: false, + }; + let json = serde_json::to_string(&response).unwrap(); + let restored: ResponseResult = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); +} + #[test] fn plugin_action_list_and_invoke_round_trips() { let list = Request { diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index bfc63252..1b7132c4 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -6,6 +6,9 @@ use super::common::AgentStatus; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkspaceCreateParams { + /// Workspace whose focused pane supplies the `follow` cwd policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default)] diff --git a/src/api/server.rs b/src/api/server.rs index 3c42d699..ff0b6fdf 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -64,14 +64,6 @@ pub(crate) fn start_server_with_stop_control( start_server_inner(api_tx, event_hub, default_capabilities(), Some(server_stop)) } -pub fn start_server_with_capabilities( - api_tx: ApiRequestSender, - event_hub: EventHub, - capabilities: Option, -) -> std::io::Result { - start_server_inner(api_tx, event_hub, capabilities, None) -} - fn default_capabilities() -> Option { Some(ServerCapabilities { live_handoff: crate::platform::capabilities().live_handoff, @@ -388,6 +380,9 @@ fn api_method_name(method: &Method) -> &'static str { Method::ServerAgentManifests(_) => "server.agent_manifests", Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests", Method::NotificationShow(_) => "notification.show", + Method::ProductAnnouncementDismiss(_) => "product_announcement.dismiss", + Method::ReleaseNotesDismiss(_) => "release_notes.dismiss", + Method::CommandInvoke(_) => "command.invoke", Method::ClientWindowTitleSet(_) => "client.window_title.set", Method::ClientWindowTitleClear(_) => "client.window_title.clear", Method::SessionSnapshot(_) => "session.snapshot", @@ -436,11 +431,17 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneEdges(_) => "pane.edges", Method::PaneFocusDirection(_) => "pane.focus_direction", Method::PaneResize(_) => "pane.resize", + Method::PaneScroll(_) => "pane.scroll", + Method::PaneEditScrollback(_) => "pane.edit_scrollback", + Method::PaneSelectionRead(_) => "pane.selection.read", + Method::PaneCopyMotion(_) => "pane.copy_motion", + Method::PaneCopySearch(_) => "pane.copy_search", Method::PaneList(_) => "pane.list", Method::PaneCurrent(_) => "pane.current", Method::PaneGet(_) => "pane.get", Method::PaneFocus(_) => "pane.focus", Method::PaneInputSet(_) => "pane.input.set", + Method::PaneLinkActivate(_) => "pane.link.activate", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", @@ -464,6 +465,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::EventsSubscribe(_) => "events.subscribe", Method::EventsWait(_) => "events.wait", Method::PaneWaitForOutput(_) => "pane.wait_for_output", + Method::IntegrationList(_) => "integration.list", Method::IntegrationInstall(_) => "integration.install", Method::IntegrationUninstall(_) => "integration.uninstall", Method::PluginLink(_) => "plugin.link", diff --git a/src/app/actions.rs b/src/app/actions.rs index 07f7278b..85839322 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,7 +3,7 @@ use std::time::Instant; -use tracing::{info, warn}; +use tracing::warn; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; @@ -16,10 +16,8 @@ use crate::workspace::WorkspaceGitStatus; use super::api_helpers::pane_agent_status; use super::state::{ - navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, - text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, - NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, - ToastNotification, ToastTarget, ViewLayout, + AgentNotificationDelivery, AppState, Mode, PaneFocusTarget, PendingAgentNotification, + ToastKind, ToastNotification, ToastTarget, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -256,7 +254,7 @@ pub struct PaneStateUpdate { } // --------------------------------------------------------------------------- -// Navigator operations +// Focus tracking // --------------------------------------------------------------------------- impl AppState { @@ -270,18 +268,6 @@ impl AppState { }) } - pub(crate) fn pane_focus_target_indices( - &self, - target: &PaneFocusTarget, - ) -> Option<(usize, usize)> { - let ws_idx = self - .workspaces - .iter() - .position(|ws| ws.id == target.workspace_id)?; - let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?; - Some((ws_idx, tab_idx)) - } - pub(crate) fn record_pane_focus_change( &mut self, previous: Option, @@ -307,14 +293,6 @@ impl AppState { } } - fn sync_selection_after_focus_navigation(&mut self) { - if self.copy_mode.is_some() { - self.sync_copy_mode_with_focus(); - } else { - self.clear_selection(); - } - } - pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(ws) = self.workspaces.get(ws_idx) else { return false; @@ -331,9 +309,6 @@ impl AppState { return false; } - if self.copy_mode.is_some() { - self.clear_copy_mode_selection(); - } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces @@ -343,649 +318,10 @@ impl AppState { tab.layout.focus_pane(pane_id); self.previous_pane_focus = previous; self.mark_session_dirty(); - self.sync_copy_mode_with_focus(); return true; } false } - - #[cfg(test)] - pub(crate) fn open_navigator(&mut self) { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.open_navigator_from(&terminal_runtimes); - } - - pub(crate) fn open_navigator_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - self.navigator.query.clear(); - self.navigator.search_focused = false; - self.navigator.state_filter = None; - self.navigator.scroll = 0; - self.navigator.expanded_workspaces.clear(); - - for ws in &self.workspaces { - self.navigator.expanded_workspaces.insert(ws.id.clone()); - } - - self.mode = Mode::Navigator; - self.navigator.selected = self - .current_navigator_row_index_from(terminal_runtimes) - .unwrap_or(0); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn navigator_rows(&self) -> Vec { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.navigator_rows_from(&terminal_runtimes) - } - - pub(crate) fn navigator_rows_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Vec { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - let mut rows = Vec::new(); - for (ws_idx, ws) in self.workspaces.iter().enumerate() { - let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes); - let activity = workspace_activity_summary(ws, &self.terminals); - let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase(); - let workspace_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - let (state, seen) = ws.aggregate_state(&self.terminals); - navigator_state_filter_matches(filter, state, seen) - } - NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), - }; - - let child_rows = - self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches); - if !workspace_matches && child_rows.is_empty() { - continue; - } - - let expanded = !matches!(query_kind, NavigatorQueryKind::Empty) - || self.navigator.expanded_workspaces.contains(&ws.id); - let (state, seen) = ws.aggregate_state(&self.terminals); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); - rows.push(NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx }, - depth: 0, - label: format!("{workspace_label} ({pane_count})"), - meta: activity, - status: state, - seen, - is_current: self.active == Some(ws_idx), - is_workspace: true, - is_tab: false, - expanded, - search_text: workspace_search_text, - matched: workspace_matches, - }); - if expanded { - rows.extend(child_rows); - } - } - rows - } - - fn navigator_child_rows( - &self, - ws_idx: usize, - query_kind: NavigatorQueryKind, - query: &str, - workspace_matches: bool, - ) -> Vec { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let multi_tab = ws.tabs.len() > 1; - let mut rows = Vec::new(); - for tab_idx in 0..ws.tabs.len() { - let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx); - let tab_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - navigator_state_filter_matches(filter, tab_row.status, tab_row.seen) - } - NavigatorQueryKind::Text => navigator_matches( - query, - if multi_tab { - &tab_row.search_text - } else { - &tab_row.label - }, - ), - }; - tab_row.matched = tab_matches; - let show_tab_row = - multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches); - let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row); - let filtered_panes = match query_kind { - NavigatorQueryKind::Empty => pane_rows, - NavigatorQueryKind::State(filter) => pane_rows - .into_iter() - .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen)) - .collect::>(), - // A matching workspace or tab shows its whole subtree; panes - // keep their own match flag so context rows can be dimmed. - NavigatorQueryKind::Text if workspace_matches || tab_matches => { - for row in pane_rows.iter_mut() { - row.matched = navigator_matches(query, &row.search_text); - } - pane_rows - } - NavigatorQueryKind::Text => pane_rows - .into_iter() - .filter(|row| navigator_matches(query, &row.search_text)) - .collect::>(), - }; - - if show_tab_row && (tab_matches || !filtered_panes.is_empty()) { - rows.push(tab_row); - } - rows.extend(filtered_panes); - } - rows - } - - fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { - let ws = &self.workspaces[ws_idx]; - let tab = &ws.tabs[tab_idx]; - let label = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - let (status, seen) = tab_aggregate_state(tab, &self.terminals); - let activity = tab_activity_summary(tab, &self.terminals); - let pane_count = tab.panes.len(); - let meta = if activity.is_empty() { - format!("{pane_count} panes") - } else { - format!("{pane_count} panes · {activity}") - }; - let search_text = format!("{label} {meta}").to_lowercase(); - NavigatorRow { - target: NavigatorTarget::Tab { ws_idx, tab_idx }, - depth: 1, - label, - meta, - status, - seen, - is_current: false, - is_workspace: false, - is_tab: true, - expanded: true, - search_text, - matched: true, - } - } - - fn navigator_pane_rows_for_tab( - &self, - ws_idx: usize, - tab_idx: usize, - show_tab_row: bool, - ) -> Vec { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return Vec::new(); - }; - let mut rows = Vec::new(); - for pane_id in tab.layout.pane_ids() { - let Some(pane) = tab.panes.get(&pane_id) else { - continue; - }; - let terminal = self.terminals.get(&pane.attached_terminal_id); - let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); - let label = terminal - .and_then(|terminal| terminal.effective_title()) - .or_else(|| { - terminal - .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal - .and_then(|terminal| terminal.effective_agent_label().map(str::to_string)) - }) - .or_else(|| { - launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) - }) - .unwrap_or_else(|| format!("pane {pane_number}")); - let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent()); - let agent_label = display_agent.as_deref().or_else(|| { - terminal - .and_then(|terminal| terminal.agent_name.as_deref()) - .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) - }); - let state = terminal - .map(|terminal| terminal.state) - .unwrap_or(AgentState::Unknown); - let status_label = terminal - .map(|terminal| terminal.effective_presentation().state_labels) - .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); - let status = status_label - .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); - let meta = match (agent_label, status.as_deref()) { - (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), - (Some(agent_label), None) => agent_label.to_string(), - (None, _) => "shell".to_string(), - }; - let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id); - let search_text = format!("{label} {meta}").to_lowercase(); - rows.push(NavigatorRow { - target: NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - }, - depth: if show_tab_row { 2 } else { 1 }, - label, - meta, - status: state, - seen: pane.seen, - is_current, - is_workspace: false, - is_tab: false, - expanded: false, - search_text, - matched: true, - }); - } - rows - } - - fn current_navigator_row_index_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Option { - let rows = self.navigator_rows_from(terminal_runtimes); - rows.iter() - .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current) - .or_else(|| rows.iter().position(|row| row.is_current)) - } - - pub(crate) fn ensure_navigator_selection_visible_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let body = self.navigator_body_rect(); - let viewport = body.height as usize; - if viewport == 0 { - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - let max_scroll = lines.len().saturating_sub(viewport); - let selected_line = - navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0); - if selected_line < self.navigator.scroll { - self.navigator.scroll = selected_line; - } else if selected_line >= self.navigator.scroll.saturating_add(viewport) { - self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport); - } - self.navigator.scroll = self.navigator.scroll.min(max_scroll); - } - - pub(crate) fn navigator_max_scroll_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - viewport: usize, - ) -> usize { - if viewport == 0 { - return 0; - } - navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)) - .len() - .saturating_sub(viewport) - } - - /// After a mouse-wheel scroll, snap the selection to the first selectable - /// row at or below the top of the viewport. - pub(crate) fn align_navigator_selection_to_scroll_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) { - self.navigator.selected = row_idx; - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn move_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta: isize, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - if count == 0 { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let current = self.navigator.selected.min(count - 1) as isize; - self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize; - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// Move the selection by a distance measured in display lines (used for - /// half-page jumps), landing on the nearest selectable row in the move - /// direction. - pub(crate) fn move_navigator_selection_by_lines_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta_lines: isize, - ) { - let rows = self.navigator_rows_from(terminal_runtimes); - if rows.is_empty() { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&rows); - let current_line = - navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1)) - .unwrap_or(0); - let target_line = - (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize; - let row_idx = if delta_lines >= 0 { - navigator_first_row_at_or_after(&lines, target_line) - } else { - lines[..=target_line] - .iter() - .rev() - .find_map(|line| match line { - super::state::NavigatorDisplayLine::Row(idx) => Some(*idx), - super::state::NavigatorDisplayLine::Spacer => None, - }) - }; - if let Some(row_idx) = row_idx { - self.navigator.selected = row_idx; - } - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// After the query or state filter changes, select the first row that - /// itself matched the filter, so enter immediately accepts the best match. - /// State filters prefer pane matches over aggregate workspace/tab matches. - pub(crate) fn select_first_navigator_match_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - if !matches!(query_kind, NavigatorQueryKind::Empty) { - let rows = self.navigator_rows_from(terminal_runtimes); - let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) { - rows.iter() - .position(|row| { - row.matched && matches!(row.target, NavigatorTarget::Pane { .. }) - }) - .or_else(|| rows.iter().position(|row| row.matched)) - } else { - rows.iter().position(|row| row.matched) - }; - if let Some(idx) = idx { - self.navigator.selected = idx; - } - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn clamp_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1)); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - pub(crate) fn toggle_selected_navigator_workspace_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return; - }; - let NavigatorTarget::Workspace { ws_idx } = row.target else { - return; - }; - let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else { - return; - }; - if self.navigator.expanded_workspaces.contains(&workspace_id) { - self.navigator.expanded_workspaces.remove(&workspace_id); - } else { - self.navigator.expanded_workspaces.insert(workspace_id); - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn accept_navigator_selection(&mut self) -> bool { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.accept_navigator_selection_from(&terminal_runtimes) - } - - pub(crate) fn accept_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return false; - }; - self.focus_navigator_target(row.target) - } - - pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { - match target { - NavigatorTarget::Workspace { ws_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - self.switch_workspace(ws_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Tab { ws_idx, tab_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - let tab_exists = self - .workspaces - .get(ws_idx) - .is_some_and(|ws| tab_idx < ws.tabs.len()); - if !tab_exists { - return false; - } - self.switch_workspace_tab(ws_idx, tab_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => { - if ws_idx >= self.workspaces.len() { - return false; - } - if self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .is_some_and(|tab| tab.panes.contains_key(&pane_id)) - { - self.focus_pane_in_workspace(ws_idx, pane_id); - self.mode = Mode::Terminal; - return true; - } - false - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NavigatorQueryKind { - Empty, - Text, - State(NavigatorStateFilter), -} - -fn navigator_query_kind( - query: &str, - state_filter: Option, -) -> NavigatorQueryKind { - if let Some(filter) = state_filter { - return NavigatorQueryKind::State(filter); - } - if query.is_empty() { - NavigatorQueryKind::Empty - } else { - NavigatorQueryKind::Text - } -} - -fn navigator_state_filter_matches( - filter: NavigatorStateFilter, - state: AgentState, - seen: bool, -) -> bool { - match filter { - NavigatorStateFilter::Blocked => state == AgentState::Blocked, - NavigatorStateFilter::Working => state == AgentState::Working, - NavigatorStateFilter::Idle => state == AgentState::Idle && seen, - NavigatorStateFilter::Done => state == AgentState::Idle && !seen, - } -} - -fn navigator_matches(query: &str, text: &str) -> bool { - text_matches_query(query, text) -} - -fn launch_label(argv: Option<&Vec>) -> Option { - let argv = argv?; - let command = argv.first()?; - std::path::Path::new(command) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string) - .or_else(|| Some(command.clone())) -} - -fn state_label_text(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "unknown", - } -} - -fn tab_aggregate_state( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> (AgentState, bool) { - let mut aggregate = AgentState::Unknown; - let mut seen = true; - for pane in tab.panes.values() { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) { - aggregate = terminal.state; - seen = pane.seen; - } - } - (aggregate, seen) -} - -fn state_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 5, - (AgentState::Working, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Idle, true) => 2, - (AgentState::Unknown, _) => 1, - } -} - -fn tab_activity_summary( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(tab.panes.values(), terminals) -} - -fn workspace_activity_summary( - ws: &crate::workspace::Workspace, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals) -} - -fn activity_summary_for_panes<'a>( - panes: impl Iterator, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - let mut blocked = 0usize; - let mut working = 0usize; - let mut done = 0usize; - for pane in panes { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - match (terminal.state, pane.seen) { - (AgentState::Blocked, _) => blocked += 1, - (AgentState::Working, _) => working += 1, - (AgentState::Idle, false) => done += 1, - _ => {} - } - } - - let mut parts = Vec::new(); - if blocked > 0 { - parts.push(format!("{blocked} blocked")); - } - if working > 0 { - parts.push(format!("{working} working")); - } - if done > 0 { - parts.push(format!("{done} done")); - } - parts.join(" · ") } // --------------------------------------------------------------------------- @@ -1126,7 +462,6 @@ impl AppState { let workspace_id = self.workspaces[idx].id.clone(); crate::logging::workspace_focused(&workspace_id); self.mark_session_dirty(); - self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; ws.switch_tab(active_tab); @@ -1134,10 +469,7 @@ impl AppState { public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1162,99 +494,16 @@ impl AppState { crate::logging::workspace_focused(&workspace_id); } self.mark_session_dirty(); - self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); let tab_id = public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); true } - pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { - if idx >= self.workspaces.len() { - return; - } - - if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate { - self.ensure_mobile_workspace_visible(idx); - return; - } - - if self.sidebar_collapsed { - return; - } - - let entries = crate::ui::workspace_list_entries(self); - let Some(target_entry_idx) = entries.iter().position(|entry| { - matches!( - entry, - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx - ) - }) else { - return; - }; - - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.iter().any(|card| card.ws_idx == idx) { - return; - } - - if target_entry_idx < self.workspace_scroll { - self.workspace_scroll = target_entry_idx; - return; - } - - while !cards.iter().any(|card| card.ws_idx == idx) { - let previous_scroll = self.workspace_scroll; - self.workspace_scroll = self.workspace_scroll.saturating_add(1); - if self.workspace_scroll == previous_scroll { - break; - } - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - if self.workspace_scroll == previous_scroll { - break; - } - cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.is_empty() { - break; - } - } - } - - fn ensure_mobile_workspace_visible(&mut self, idx: usize) { - let viewport = crate::ui::mobile_switcher_areas(self).viewport; - if viewport.height == 0 { - return; - } - - let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx); - let visible_start = self.mobile_switcher_scroll; - let visible_end = visible_start.saturating_add(viewport.height as usize); - if row_range.start < visible_start { - self.mobile_switcher_scroll = row_range.start; - } else if row_range.end > visible_end { - self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize); - } - self.mobile_switcher_scroll = self - .mobile_switcher_scroll - .min(crate::ui::mobile_switcher_max_scroll(self)); - } - #[cfg(test)] pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { @@ -1267,10 +516,7 @@ impl AppState { let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); self.mark_session_dirty(); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1296,77 +542,6 @@ impl AppState { changed } - pub(crate) fn visible_workspace_order(&self) -> Vec { - // Mobile always shows the worktree tree expanded, so its visible order - // must ignore collapse state to match what the switcher renders. - let entries = if self.view.layout == ViewLayout::Mobile { - crate::ui::workspace_list_entries_expanded(self) - } else { - crate::ui::workspace_list_entries(self) - }; - let order = entries - .into_iter() - .map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx, - }) - .collect::>(); - if order.is_empty() { - (0..self.workspaces.len()).collect() - } else { - order - } - } - - pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option { - self.visible_workspace_order().get(position).copied() - } - - pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) { - if self.workspaces.is_empty() { - return; - } - let order = self.visible_workspace_order(); - let current_pos = order - .iter() - .position(|idx| *idx == self.selected) - .unwrap_or(0); - let target_pos = current_pos - .saturating_add_signed(delta) - .min(order.len().saturating_sub(1)); - if let Some(ws_idx) = order.get(target_pos).copied() { - self.selected = ws_idx; - self.ensure_workspace_visible(ws_idx); - } - } - - #[cfg(test)] - pub fn next_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = order[(current_pos + 1) % order.len()]; - self.switch_workspace(next); - } - - #[cfg(test)] - pub fn previous_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let prev = if current_pos == 0 { - order[order.len() - 1] - } else { - order[current_pos - 1] - }; - self.switch_workspace(prev); - } - pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool { if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { return false; @@ -1396,7 +571,6 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } @@ -1463,119 +637,9 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } - pub fn scroll_tabs_left(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_sub(1); - self.refresh_tab_bar_view(); - } - - pub fn scroll_tabs_right(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_add(1); - self.refresh_tab_bar_view(); - } - - #[cfg(test)] - pub fn next_tab(&mut self) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let next = (ws.active_tab + 1) % ws.tabs.len(); - self.switch_tab(next); - } - } - } - - #[cfg(test)] - pub fn previous_tab(&mut self) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let prev = if ws.active_tab == 0 { - ws.tabs.len() - 1 - } else { - ws.active_tab - 1 - }; - self.switch_tab(prev); - } - } - } - - #[cfg(test)] - pub fn next_agent(&mut self) { - self.cycle_agent_entry(true); - } - - #[cfg(test)] - pub fn previous_agent(&mut self) { - self.cycle_agent_entry(false); - } - - #[cfg(test)] - pub fn focus_agent_entry(&mut self, idx: usize) -> bool { - let entries = crate::ui::agent_panel_entries(self); - let Some(target) = entries.get(idx) else { - return false; - }; - let ws_idx = target.ws_idx; - let pane_id = target.pane_id; - - if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id) - { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - - if self.focus_pane_in_workspace(ws_idx, pane_id) { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - false - } - - #[cfg(test)] - fn cycle_agent_entry(&mut self, forward: bool) { - let entries = crate::ui::agent_panel_entries(self); - if entries.is_empty() { - return; - } - - let focused = self - .active - .and_then(|idx| self.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = - focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id)); - let target_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - - self.focus_agent_entry(target_idx); - } - - pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { - if self.sidebar_collapsed { - return; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( - self, - detail_area, - self.agent_panel_scroll, - idx, - ); - } - pub(crate) fn terminal_ids_for_workspace( &self, ws_idx: usize, @@ -1657,7 +721,6 @@ impl AppState { pane_ids: impl IntoIterator, ) { let pane_ids = pane_ids.into_iter().collect::>(); - self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied()); if self .previous_pane_focus .as_ref() @@ -1674,8 +737,6 @@ impl AppState { if self.workspaces.is_empty() { return; } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let close_indices = self.workspace_close_indices(self.selected); @@ -1700,9 +761,6 @@ impl AppState { if self.workspaces.is_empty() { self.active = None; self.selected = 0; - self.workspace_scroll = 0; - self.tab_scroll = 0; - self.tab_scroll_follow_active = true; } else { // Keep focus on the previously focused workspace if let Some(id) = active_workspace_id { @@ -1714,39 +772,8 @@ impl AppState { self.selected = self.workspaces.len() - 1; } self.active = Some(self.selected); - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } } - - pub(crate) fn refresh_tab_bar_view(&mut self) { - let area = self.view.tab_bar_rect; - let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else { - self.tab_scroll = 0; - self.view.tab_hit_areas.clear(); - self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default(); - self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default(); - self.view.new_tab_hit_area = ratatui::layout::Rect::default(); - return; - }; - - let layout = crate::ui::compute_tab_bar_view( - ws, - crate::ui::tab_bar_content_area(self, area), - self.tab_scroll, - self.tab_scroll_follow_active, - self.mouse_capture, - ); - self.tab_scroll = layout.scroll; - self.view.tab_hit_areas = layout.tab_hit_areas; - self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area; - self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area; - self.view.new_tab_hit_area = layout.new_tab_hit_area; - } } // --------------------------------------------------------------------------- @@ -1852,52 +879,6 @@ impl AppState { } } - #[cfg(test)] - pub fn cycle_pane(&mut self, reverse: bool) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { - return; - }; - let ids = tab.layout.pane_ids(); - if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) { - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_in_workspace(ws_idx, target); - } - } - - #[cfg(test)] - pub fn last_pane(&mut self) { - let Some(target) = self.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else { - self.previous_pane_focus = None; - return; - }; - let current = self.current_pane_focus_target(); - if current.as_ref() == Some(&target) { - self.previous_pane_focus = None; - return; - } - - self.switch_workspace_tab(ws_idx, tab_idx); - if let Some(tab) = self - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.layout.focus_pane(target.pane_id); - self.previous_pane_focus = current; - self.mark_session_dirty(); - } - } - pub(crate) fn apply_pane_zoom( &mut self, ws_idx: usize, @@ -1991,31 +972,8 @@ impl AppState { self.workspace_close_indices(ws_idx).len() >= 2 } - pub(crate) fn begin_workspace_close_confirmation(&mut self, ws_idx: usize) -> bool { - let Some(workspace_id) = self - .workspaces - .get(ws_idx) - .map(|workspace| workspace.id.clone()) - else { - return false; - }; - self.selected = ws_idx; - self.confirm_close_workspace_id = Some(workspace_id); - self.mode = Mode::ConfirmClose; - true - } - - pub(crate) fn take_confirmed_workspace_close_index(&mut self) -> Option { - let workspace_id = self.confirm_close_workspace_id.take()?; - self.workspaces - .iter() - .position(|workspace| workspace.id == workspace_id) - } - - pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool { - self.confirm_close - && self.workspace_close_would_close_worktree_group(ws_idx) - && self.begin_workspace_close_confirmation(ws_idx) + pub(crate) fn confirm_implicit_worktree_group_close(&self, ws_idx: usize) -> bool { + self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) } #[cfg(test)] @@ -2052,8 +1010,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let terminal_ids = active .and_then(|i| { @@ -2099,8 +1055,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let should_close_workspace = self .active @@ -2134,173 +1088,70 @@ impl AppState { self.remove_plugin_pane_records(pane_ids); self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } false } } -// --------------------------------------------------------------------------- -// Selection -// --------------------------------------------------------------------------- - impl AppState { - pub fn clear_selection(&mut self) { - self.selection = None; - self.selection_autoscroll = None; - } - - pub(crate) fn stop_selection_autoscroll_state(&mut self) { - self.selection_autoscroll = None; - } - - pub(crate) fn select_word_at_pane_cell( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - ) -> bool { - // Resolve the active pane cell the double-click landed on. - let Some(ws_idx) = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some()) - else { - return false; - }; - - let Some(info) = self.pane_info_by_id(pane_id) else { - return false; - }; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return false; - } - - // Leave mouse input to terminal apps that requested it. - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return false; - }; - if rt.mouse_reporting_enabled() { - return false; - } - - // Read the visible row and identify the clicked token bounds. - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let row_selection = Selection::range( - pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - let Some(row_text) = rt.extract_selection(&row_selection) else { - return false; - }; - let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else { - return false; - }; - - let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics); - if !selection.finish() { - return false; - } - - let text = if self.copy_on_select { - let Some(text) = rt - .extract_selection(&selection) - .filter(|text| !text.is_empty()) - else { - self.clear_selection(); - return false; - }; - Some(text) - } else { - None - }; - - self.selection = Some(selection); - self.selection_autoscroll = None; - if let Some(text) = text { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied double-clicked token to clipboard"); - } - true - } - - pub(crate) fn url_at_pane_cell( + 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 ws_idx = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some())?; - let info = self.pane_info_by_id(pane_id)?; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return None; - } - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let screen_col = info.inner_rect.x.saturating_add(col); - let screen_row = info.inner_rect.y.saturating_add(viewport_row); - if let Some((_, _, uri)) = rt - .visible_hyperlinks(info.inner_rect) - .into_iter() - .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) - { - return Some(uri); - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let visible_selection = Selection::line_range( + let (height, width) = rt.current_size(); + url_at_runtime_cell( + rt, pane_id, - Selection::absolute_row_for_viewport(0, metrics), - Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics), - info.inner_rect.width.saturating_sub(1), - ); - let visible_text = rt.extract_selection(&visible_selection)?; - let logical_cell = - logical_cell_for_visible_cell(&visible_text, info.inner_rect.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) + 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; + } + 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); } - pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { - let mut sel = match self.selection.take() { - Some(sel) => sel, - None => return, - }; - if !sel.is_finalized() && !sel.finish() { - return; - } - - let ws_idx = match self.active { - Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx, - _ => return, - }; - - let text = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id) - .and_then(|rt| rt.extract_selection(&sel)); - if let Some(text) = text { - if !text.is_empty() { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied selection to clipboard"); - } - } - - self.clear_selection(); - } + 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> { @@ -2336,7 +1187,7 @@ impl CellSpan { /// zero-width marks use display columns, then prefers structured spans that /// users expect to copy whole (URLs and quoted paths), and finally falls back /// to a separator-delimited token. -fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { +pub(crate) fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { // Map the row into display cells before doing any word-boundary work. let cells = text_cells(row); let clicked_idx = cell_index_at_column(&cells, col)?; @@ -2921,12 +1772,10 @@ impl AppState { .collect() } } - // Intercepted before this dispatch — in App::handle_internal_event (monolithic) - // or via HeadlessServer forwarding to the foreground client (server); never touch - // AppState. Kept for AppEvent exhaustiveness. + // Host-local effects are intercepted by HeadlessServer and forwarded to the + // foreground client; they never touch AppState. Kept for AppEvent exhaustiveness. AppEvent::TerminalBell { .. } => Vec::new(), AppEvent::ClipboardWrite { .. } => Vec::new(), - AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { if !cwd.is_absolute() || !cwd.is_dir() { return Vec::new(); @@ -3051,33 +1900,6 @@ impl AppState { .min() } - pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { - let mut changed_terminals = std::collections::HashSet::new(); - for (terminal_id, terminal) in &mut self.terminals { - if terminal.reconcile_managed_agent_at(now, false) { - changed_terminals.insert(terminal_id.clone()); - } - } - if changed_terminals.is_empty() { - return Vec::new(); - } - self.mark_session_dirty(); - self.workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, workspace)| { - let changed_terminals = &changed_terminals; - workspace.tabs.iter().flat_map(move |tab| { - tab.panes.iter().filter_map(move |(&pane_id, pane)| { - changed_terminals - .contains(&pane.attached_terminal_id) - .then_some((ws_idx, pane_id)) - }) - }) - }) - .collect() - } - pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, @@ -3274,12 +2096,6 @@ impl AppState { } fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) { - if self.local_sound_playback { - if let Some(sound) = delivery.sound { - crate::sound::play(sound, &self.sound); - } - } - if matches!( self.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -3350,15 +2166,6 @@ impl AppState { return; }; - if self - .selection - .as_ref() - .is_some_and(|s| s.pane_id == pane_id) - { - self.selection = None; - self.selection_autoscroll = None; - } - let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); self.pane_id_aliases.retain(|_, alias| *alias != pane_id); @@ -3404,10 +2211,6 @@ impl AppState { if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); } } else { self.remove_unattached_terminal_ids(pane_terminal_id); @@ -3666,412 +2469,6 @@ mod tests { assert_eq!(selected_url("open file:///tmp/report", "file"), None); } - #[test] - fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { - let mut state = app_with_workspaces(&["single", "multi"]); - state.workspaces[1].test_add_tab(Some("tests")); - state.ensure_test_terminals(); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 1 - } - ))); - } - - #[test] - fn navigator_search_matches_named_tabs_in_single_tab_workspaces() { - let mut state = app_with_workspaces(&["multi", "single"]); - state.workspaces[0].tabs[0].custom_name = Some("Foo".into()); - state.workspaces[0].test_add_tab(Some("Bar")); - state.workspaces[1].tabs[0].custom_name = Some("Baz".into()); - state.ensure_test_terminals(); - - state.open_navigator(); - state.navigator.query = "foo".into(); - assert!(state.navigator_rows().iter().any(|row| { - row.matched - && matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 0, - tab_idx: 0 - } - ) - })); - - state.navigator.query = "baz".into(); - state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new()); - let rows = state.navigator_rows(); - assert!(rows - .get(state.navigator.selected) - .is_some_and(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 } - | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. } - ))); - - assert!(state.accept_navigator_selection()); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab_index(), 0); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() { - let unique = format!( - "herdr-navigator-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut state = AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - state.open_navigator_from(&runtime_registry); - state.navigator.query = "herdr".into(); - let rows = state.navigator_rows_from(&runtime_registry); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - // The workspace matched by its live cwd label; its subtree cascades in - // as context. - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].label, "herdr (1)"); - assert!(rows[0].matched); - assert!(!rows[1].matched); - } - - #[test] - fn navigator_rows_include_shell_and_agent_panes() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ) && row.meta.contains("claude"))); - } - - #[test] - fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() { - let mut state = app_with_workspaces(&["one", "two"]); - let blocked = state.workspaces[1].tabs[0].root_pane; - let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap(); - state - .terminals - .get_mut(&blocked_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Blocked); - - state.open_navigator(); - let selected = state.navigator_rows()[state.navigator.selected].clone(); - - assert!(selected.is_current); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[0].id)); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[1].id)); - } - - #[test] - fn accepting_navigator_pane_switches_workspace_tab_and_focus() { - let mut state = app_with_workspaces(&["one", "two"]); - let target = state.workspaces[1].tabs[0].root_pane; - state.open_navigator(); - state - .navigator - .expanded_workspaces - .insert(state.workspaces[1].id.clone()); - state.navigator.selected = state - .navigator_rows() - .iter() - .position(|row| { - matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target - ) - }) - .unwrap(); - - assert!(state.accept_navigator_selection()); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_idle_search_matches_idle_agents_not_plain_shells() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - state - .terminals - .get_mut(&agent_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Claude), AgentState::Idle); - - state.open_navigator(); - state.navigator.query = "idle".into(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - } - - #[test] - fn navigator_search_only_matches_visible_row_text() { - let mut state = app_with_workspaces(&["one"]); - state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into(); - - state.open_navigator(); - state.navigator.query = "work".into(); - - assert!(state.navigator_rows().is_empty()); - } - - #[test] - fn navigator_state_filter_is_separate_from_text_search() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap(); - state - .terminals - .get_mut(&shell_terminal_id) - .unwrap() - .set_manual_label("wheel notes".into()); - let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&working_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - state.open_navigator(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - let state_rows = state.navigator_rows(); - - assert!(state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - ))); - assert!(!state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - - state.navigator.state_filter = None; - state.navigator.query = "w".into(); - let text_rows = state.navigator_rows(); - - assert!(text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!( - text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )), - "literal one-letter search may still match visible state text" - ); - } - - #[test] - fn navigator_search_filters_panes_but_keeps_workspace_context() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("weekly review".into()); - state.open_navigator(); - state.navigator.query = "weekly".into(); - - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| row.is_workspace)); - assert!(rows - .iter() - .any(|row| !row.is_workspace && row.label.contains("weekly"))); - } - - #[test] - fn navigator_workspace_match_cascades_full_subtree() { - let mut state = app_with_workspaces(&["one", "two"]); - let root = state.workspaces[0].tabs[0].root_pane; - let extra = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - for pane in [root, extra] { - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("unrelated".into()); - } - - state.open_navigator(); - state.navigator.query = "one".into(); - let rows = state.navigator_rows(); - - // Both panes cascade in even though only the workspace label matched, - // and only the workspace carries the matched flag. - let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect(); - assert_eq!(pane_rows.len(), 2); - assert!(pane_rows.iter().all(|row| !row.matched)); - assert!(rows.iter().any(|row| row.is_workspace && row.matched)); - assert!(!rows.iter().any(|row| row.label.starts_with("two"))); - } - - #[test] - fn navigator_search_selects_first_self_match() { - let mut state = app_with_workspaces(&["one", "two"]); - let pane = state.workspaces[1].tabs[0].root_pane; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("pi ui build".into()); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.query = "ui".into(); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane - )); - } - - #[test] - fn navigator_state_filter_selects_matching_pane_over_workspace() { - let mut state = app_with_workspaces(&["one"]); - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )); - } - #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); @@ -4219,196 +2616,6 @@ mod tests { assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); } - fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { - set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle); - } - - fn set_agent_state( - state: &mut AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - agent_state: AgentState, - ) { - state.ensure_test_terminals(); - let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] - .panes - .get(&pane_id) - .unwrap() - .attached_terminal_id - .clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_detected_state(Some(Agent::Pi), agent_state); - } - } - - fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) { - state - .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - agent_state, - matches!(agent_state, AgentState::Blocked), - false, - false, - false, - std::time::Instant::now(), - )) - }) - .expect("agent state transition should update pane state"); - } - - #[test] - fn next_agent_cycles_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - state.next_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - - state.next_agent(); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - - state.previous_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_uses_agent_panel_order() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - assert!(state.focus_agent_entry(2)); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_succeeds_for_already_focused_agent() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - mark_agent(&mut state, 0, 0, root); - - assert!(state.focus_agent_entry(0)); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - state.assert_invariants_for_test(); - } - - #[test] - fn next_agent_cycles_priority_sorted_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle); - set_agent_state(&mut state, 0, 0, first_second, AgentState::Working); - set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked); - - state.next_agent(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() { - let mut workspace = Workspace::test_new("one"); - let first = workspace.tabs[0].root_pane; - let second = workspace.test_split(Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first); - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - transition_agent_state(&mut state, first, AgentState::Idle); - transition_agent_state(&mut state, second, AgentState::Working); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - - transition_agent_state(&mut state, second, AgentState::Idle); - - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - state.assert_invariants_for_test(); - } - - #[test] - fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() { - let mut workspace = Workspace::test_new("one"); - let root = workspace.tabs[0].root_pane; - for idx in 1..8 { - workspace.test_add_tab(Some(&format!("tab-{idx}"))); - } - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - for tab_idx in 0..state.workspaces[0].tabs.len() { - let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; - mark_agent(&mut state, 0, tab_idx, pane_id); - } - state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.previous_agent(); - - let last_idx = state.workspaces[0].tabs.len() - 1; - assert_eq!(state.workspaces[0].active_tab, last_idx); - assert!(state.agent_panel_scroll > 0); - state.assert_invariants_for_test(); - } - #[test] fn switch_workspace_updates_active_and_selected() { let mut state = app_with_workspaces(&["a", "b", "c"]); @@ -4417,137 +2624,6 @@ mod tests { assert_eq!(state.selected, 2); } - #[test] - fn last_pane_toggles_to_previous_focus_in_active_tab() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn removing_background_pane_preserves_last_pane_history() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - let background = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.workspaces[0].remove_pane(background); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - } - - #[test] - fn last_pane_jumps_across_workspaces_and_tabs() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.focus_pane_in_workspace(0, first_root); - state.focus_pane_in_workspace(1, second_tab_root); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - } - - #[test] - fn last_pane_tracks_tab_and_workspace_switches() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let first_second_tab = state.workspaces[0].test_add_tab(Some("logs")); - let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane; - let second_root = state.workspaces[1].tabs[0].root_pane; - - state.switch_tab(first_second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.switch_workspace(1); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[test] - fn last_pane_tracks_cross_workspace_tab_selection() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_first_root = state.workspaces[1].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.switch_workspace_tab(1, second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - assert_ne!(second_first_root, second_tab_root); - } - - #[test] - fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { - let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.switch_workspace(7); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - assert!(state - .view - .workspace_card_areas - .iter() - .any(|card| card.ws_idx == 7)); - } - #[test] fn switch_workspace_marks_panes_seen() { let mut state = app_with_workspaces(&["a", "b"]); @@ -4737,34 +2813,6 @@ mod tests { state.assert_invariants_for_test(); } - #[test] - fn pane_died_self_closing_earlier_workspace_keeps_focus() { - let names = (0..20).map(|i| format!("ws{i:02}")).collect::>(); - let name_refs = names.iter().map(String::as_str).collect::>(); - let mut state = app_with_workspaces(&name_refs); - state.selected = 1; - state.active = Some(1); - // Constrain the sidebar viewport so the focused workspace starts - // off-screen: 20 workspaces cannot all fit in 12 rows, so index 1 is - // hidden at maximum scroll regardless of individual card height. - state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 30, 12); - state.workspace_scroll = - crate::ui::normalized_workspace_scroll(&state, state.view.sidebar_rect, usize::MAX / 2); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().all(|card| card.ws_idx != 1)); - - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - state.handle_pane_died(pane_id); - - assert_eq!(state.workspaces.len(), 19); - assert_eq!(state.workspaces[0].display_name(), "ws01"); - assert_eq!(state.selected, 0); - assert_eq!(state.active, Some(0)); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().any(|card| card.ws_idx == 0)); - state.assert_invariants_for_test(); - } - #[test] fn pane_died_last_pane_removes_workspace() { let mut state = app_with_workspaces(&["a", "b"]); @@ -4813,55 +2861,6 @@ mod tests { assert_eq!(state.workspaces.len(), 1); state.assert_invariants_for_test(); } - - #[test] - fn pane_died_unrelated_pane_preserves_selection() { - // Two workspaces; user is selecting text in workspace 0. - // A pane in workspace 1 dies — selection must be preserved. - let mut state = app_with_workspaces(&["active", "bg"]); - let active_pane = *state.workspaces[0].panes.keys().next().unwrap(); - let bg_pane = *state.workspaces[1].panes.keys().next().unwrap(); - - state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(bg_pane); - - assert!(state.selection.is_some()); - assert!(state.selection_autoscroll.is_some()); - state.assert_invariants_for_test(); - } - - #[test] - fn pane_died_same_pane_clears_selection() { - let mut state = app_with_workspaces(&["test"]); - let first_id = state.workspaces[0].tabs[0].root_pane; - let second_id = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(second_id); - - // first_id still alive, workspace stays, but selection was on the dying pane - assert!(state.selection.is_none()); - assert!(state.selection_autoscroll.is_none()); - assert_eq!(state.workspaces[0].panes.len(), 1); - assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); - state.assert_invariants_for_test(); - } - #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); @@ -5832,13 +3831,21 @@ mod tests { let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, root); state.navigate_pane(NavDirection::Right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); @@ -5853,7 +3860,11 @@ mod tests { let root = state.workspaces[0].tabs[0].root_pane; let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let before_root_rect = state .view .pane_infos @@ -5870,7 +3881,11 @@ mod tests { .rect; assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); assert_eq!( @@ -5902,10 +3917,18 @@ mod tests { let right = state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); @@ -5913,7 +3936,11 @@ mod tests { assert_eq!(state.view.pane_infos[0].id, root); state.workspaces[0].zoomed = false; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_rect = state .view .pane_infos @@ -6088,8 +4115,7 @@ mod tests { let deferred = state.close_pane(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6102,7 +4128,6 @@ mod tests { state.close_tab(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } @@ -6118,8 +4143,7 @@ mod tests { let deferred = state.close_tab(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6132,7 +4156,6 @@ mod tests { state.close_pane(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 29c2c317..4bc12c24 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -357,7 +357,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index ba50ee3a..58c1d7de 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -7,7 +7,7 @@ use crate::api::schema::{ }; use crate::ui::AgentPanelEntry; -use super::{AppState, Mode}; +use super::AppState; const MAX_FILTER_DEPTH: usize = 8; const MAX_FILTER_NODES: usize = 64; @@ -78,11 +78,7 @@ pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec Option { - if app.mode == Mode::Navigate { - app.workspaces.get(app.selected).map(|_| app.selected) - } else { - app.active - } + app.active } fn normalize_source(source: &str) -> Result { @@ -449,6 +445,10 @@ mod tests { state } + fn projected_entries(state: &AppState) -> Vec { + crate::ui::agent_panel_entries_from(state, &crate::terminal::TerminalRuntimeRegistry::new()) + } + fn current_workspace_view() -> AgentViewSetParams { AgentViewSetParams { source: "example.views".to_string(), @@ -468,16 +468,15 @@ mod tests { let mut state = state_with_agents(); state.agent_view_override = Some(current_workspace_view()); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0); + assert_eq!(projected_entries(&state)[0].ws_idx, 0); - state.mode = Mode::Navigate; - state.selected = 1; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(1); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 1); - state.mode = Mode::Settings; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(0); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 0); } @@ -513,7 +512,7 @@ mod tests { }], }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 2); assert_eq!(entries[0].ws_idx, 1); assert_eq!(entries[1].ws_idx, 0); @@ -547,7 +546,7 @@ mod tests { sort: Vec::new(), }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent")); } diff --git a/src/app/agents.rs b/src/app/agents.rs index b2aac01c..f25383fe 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -80,7 +80,7 @@ impl App { self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), diff --git a/src/app/api.rs b/src/app/api.rs index 5d347cf7..ff5a7dc4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -8,7 +8,7 @@ mod layouts; mod pane_graphics; mod panes; pub(crate) mod plugins; -mod responses; +pub(super) mod responses; mod session; mod tabs; mod workspaces; @@ -28,36 +28,6 @@ enum RuntimeExitAction { } impl App { - pub(crate) fn dispatch_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> String { - self.handle_api_request(crate::api::schema::Request { - id: id.to_string(), - method, - }) - } - - pub(crate) fn dispatch_deferred_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> Option { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if !self.handle_deferred_worktree_api_request( - crate::api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - ) { - return None; - } - - response_rx.try_recv().ok() - } - pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { match ev { AppEvent::GitStatusRefreshed { @@ -113,37 +83,10 @@ impl App { &mut self, ev: AppEvent, ) -> Vec { - if let AppEvent::TerminalBell { count, .. } = ev { - if let Err(err) = - crate::terminal_effects::write_terminal_bells(&mut std::io::stdout(), count) - { - tracing::warn!(err = %err, "failed to emit terminal bell"); - } - return Vec::new(); - } - - if let AppEvent::ClipboardWrite { content } = ev { - #[cfg(not(test))] - crate::selection::write_osc52_bytes(&content); - #[cfg(test)] - let _ = content; - self.show_clipboard_feedback(); - return Vec::new(); - } - - if let AppEvent::PrefixInputSource { active } = ev { - // Monolithic path applies the switch here. Server mode forwards it to the foreground - // client instead (see HeadlessServer::handle_internal_event_with_forwarding); should an - // App-internal drain consume the event before the forwarding drain, the flag keeps the - // switch out of the headless server process. - if !self.local_input_source_switch { - return Vec::new(); - } - if active { - self.prefix_input_source.switch_to_ascii(); - } else { - self.prefix_input_source.restore(); - } + if matches!( + &ev, + AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. } + ) { return Vec::new(); } @@ -198,12 +141,12 @@ impl App { } if let AppEvent::WorktreeAddFinished(result) = ev { - self.handle_worktree_add_finished(*result); + self.handle_api_worktree_add_finished(*result); return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { - self.handle_worktree_remove_finished(*result); + self.handle_api_worktree_remove_finished(*result); return Vec::new(); } @@ -222,7 +165,6 @@ impl App { self.sync_full_lifecycle_authority_detection_pauses(); self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.emit_pane_state_update(&update); - self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update)); } if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id) @@ -308,6 +250,9 @@ impl App { let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); + if update_ready.is_some() { + self.state.latest_release_notes = crate::release_notes::load_latest(); + } if let Some(agents) = manifest_update_agents { self.reset_agent_detection_for_agents(&agents); } @@ -353,26 +298,6 @@ impl App { self.emit_layout_updated_event(ws_idx, tab_idx); } - if self.local_terminal_notifications - && matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => unreachable!("toast delivery was checked above"), - }; - - if let Some((version, install_command)) = update_ready { - let instruction = crate::update::update_install_instruction(&install_command); - let _ = notify(&format!("v{version} available"), Some(&instruction)); - } else if self.state.toast_config.delay_seconds == 0 { - self.emit_terminal_or_system_agent_notifications(&pane_updates); - } - } - self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); pane_updates @@ -464,18 +389,6 @@ impl App { } } - pub(crate) fn show_clipboard_feedback(&mut self) { - if !self.state.toast_config.clipboard.enabled { - self.state.copy_feedback = None; - self.copy_feedback_deadline = None; - return; - } - self.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_string(), - }); - self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION); - } - fn restore_overlay_after_exit( &mut self, overlay: OverlayPaneState, @@ -660,78 +573,6 @@ impl App { } } - fn emit_terminal_or_system_agent_notifications( - &self, - pane_updates: &[crate::app::actions::PaneStateUpdate], - ) { - if !self.local_terminal_notifications - || self.state.toast_config.delay_seconds != 0 - || !matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - return; - } - - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => return, - }; - - for update in pane_updates { - let is_active_tab = self - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - crate::app::actions::active_tab_suppresses_notifications( - is_active_tab, - self.state.outer_terminal_focus, - ); - let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( - suppress_active_tab_notifications, - update, - ) else { - continue; - }; - let Some(ws) = self.state.workspaces.get(update.ws_idx) else { - continue; - }; - let Some(pane) = ws - .tabs - .iter() - .find_map(|tab| tab.panes.get(&update.pane_id)) - else { - continue; - }; - let Some(agent_label) = self - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - else { - continue; - }; - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); - let _ = notify( - &format!("{} {}", agent_label, event_text), - Some(&crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - )), - ); - } - } - pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option, @@ -748,33 +589,6 @@ impl App { } } - pub(crate) fn emit_delayed_client_local_agent_notifications( - &self, - deliveries: &[crate::app::state::AgentNotificationDelivery], - ) { - if !self.local_terminal_notifications - || !matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - return; - } - - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => unreachable!("toast delivery was checked above"), - }; - - for delivery in deliveries { - let Some(toast) = &delivery.client_notification else { - continue; - }; - let _ = notify(&toast.title, Some(&toast.context)); - } - } - pub(crate) fn refresh_agent_notification_delivery_contexts( &mut self, deliveries: &mut [crate::app::state::AgentNotificationDelivery], @@ -843,7 +657,7 @@ impl App { self.sync_focus_events_with_outer_event(None); } - pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { + pub(crate) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { self.sync_focus_events_with_outer_event(Some(event)); } @@ -922,6 +736,7 @@ impl App { runtime.try_send_focus_event(event); } + #[cfg(test)] pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_all_internal_events(); self.handle_api_request_after_internal_events_drained(request) @@ -1000,6 +815,46 @@ impl App { Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } + Method::ReleaseNotesDismiss(params) => { + let Some(notes) = self.state.latest_release_notes.as_ref() else { + return responses::encode_error( + request.id, + "stale_release_notes", + "the release notes are no longer current", + ); + }; + if notes.version != params.version { + return responses::encode_error( + request.id, + "stale_release_notes", + "the release notes are no longer current", + ); + } + let preview = notes.preview; + self.mark_release_notes_seen(preview); + return responses::encode_success(request.id, ResponseResult::Ok {}); + } + Method::ProductAnnouncementDismiss(params) => { + let matches_current = + self.state + .product_announcement + .as_ref() + .is_some_and(|announcement| { + announcement.version == params.version && announcement.id == params.id + }); + if !matches_current { + return responses::encode_error( + request.id, + "stale_announcement", + "the product announcement is no longer current", + ); + } + self.dismiss_product_announcement(); + return responses::encode_success(request.id, ResponseResult::Ok {}); + } + Method::CommandInvoke(params) => { + return self.handle_command_invoke(request.id, params); + } Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => { return responses::encode_success( request.id, @@ -1099,11 +954,27 @@ impl App { return self.handle_pane_focus_direction(request.id, params); } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), + Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params), + Method::PaneEditScrollback(target) => { + return self.handle_pane_edit_scrollback(request.id, target); + } + Method::PaneSelectionRead(params) => { + return self.handle_pane_selection_read(request.id, params); + } + Method::PaneCopyMotion(params) => { + return self.handle_pane_copy_motion(request.id, params); + } + Method::PaneCopySearch(params) => { + return self.handle_pane_copy_search(request.id, params); + } Method::PaneList(params) => return self.handle_pane_list(request.id, params), Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), 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::PaneLinkActivate(params) => { + return self.handle_pane_link_activate(request.id, params); + } Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneGraphicsSet(params) => { @@ -1162,6 +1033,9 @@ impl App { }; } Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), + Method::IntegrationList(_) => { + return self.handle_integration_list(request.id); + } Method::IntegrationInstall(params) => { return self.handle_integration_install(request.id, params); } @@ -1220,7 +1094,6 @@ impl App { ) -> String { use crate::api::schema::{NotificationShowReason, ResponseResult}; - let requested_sound = params.sound; let Some(title) = sanitized_notification_text(¶ms.title, 80) else { return responses::encode_error(id, "invalid_params", "notification title is empty"); }; @@ -1247,32 +1120,11 @@ impl App { target: None, }); self.sync_toast_deadline(previous_toast); - self.emit_api_notification_sound(requested_sound); NotificationShowReason::Shown } } crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => { - if self.api_notification_rate_limited(Instant::now()) { - NotificationShowReason::RateLimited - } else { - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => { - crate::terminal_notify::show_notification - } - crate::config::ToastDelivery::System => { - crate::platform::show_desktop_notification - } - _ => unreachable!("notification delivery was checked above"), - }; - match notify(&title, body.as_deref()) { - Ok(true) => { - self.mark_api_notification_shown(Instant::now()); - self.emit_api_notification_sound(requested_sound); - NotificationShowReason::Shown - } - Ok(false) | Err(_) => NotificationShowReason::NoForegroundClient, - } - } + NotificationShowReason::NoForegroundClient } }; @@ -1285,15 +1137,6 @@ impl App { ) } - fn emit_api_notification_sound(&self, sound: crate::api::schema::NotificationShowSound) { - if !self.state.local_sound_playback || !self.state.sound.allows(None) { - return; - } - if let Some(sound) = sound.to_sound() { - crate::sound::play(sound, &self.state.sound); - } - } - pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool { self.last_api_notification_at .is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT) @@ -1400,7 +1243,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1427,7 +1270,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1461,12 +1304,98 @@ mod tests { .expect("matching agent detection runtime should be reset"); } + #[test] + fn product_announcement_dismiss_requires_current_identity() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + scroll: 0, + preview: true, + }); + + let stale = app.handle_api_request(crate::api::schema::Request { + id: "stale".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "old".into(), + }, + ), + }); + let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); + assert_eq!(stale["error"]["code"], "stale_announcement"); + assert!(app.state.product_announcement.is_some()); + + let dismissed = app.handle_api_request(crate::api::schema::Request { + id: "dismiss".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "client-shell".into(), + }, + ), + }); + let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); + assert_eq!(dismissed["result"]["type"], "ok"); + assert!(app.state.product_announcement.is_none()); + } + + #[test] + fn release_notes_dismiss_requires_current_version() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { + version: "99.99.99".into(), + body: "release notes".into(), + preview: true, + }); + + let stale = app.handle_api_request(crate::api::schema::Request { + id: "stale".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "0.8.2".into(), + }, + ), + }); + let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); + assert_eq!(stale["error"]["code"], "stale_release_notes"); + + let dismissed = app.handle_api_request(crate::api::schema::Request { + id: "dismiss".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "99.99.99".into(), + }, + ), + }); + let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); + assert_eq!(dismissed["result"]["type"], "ok"); + assert!(app.state.latest_release_notes.is_some()); + } + #[tokio::test] async fn server_reload_agent_manifests_resets_detection_runtimes() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1507,7 +1436,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1550,7 +1479,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1595,7 +1524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1637,7 +1566,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1671,7 +1600,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1707,7 +1636,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1800,7 +1729,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1880,9 +1809,15 @@ mod tests { .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); - assert!(app.handle_scheduled_tasks(notification_deadline, false)); + let mut deliveries = app + .state + .drain_due_agent_notifications(notification_deadline); + app.refresh_agent_notification_delivery_contexts(&mut deliveries); assert_eq!( - app.state.toast.as_ref().map(|toast| toast.context.as_str()), + deliveries + .first() + .and_then(|delivery| delivery.toast.as_ref()) + .map(|toast| toast.context.as_str()), Some("__herdr_projects__ · 1") ); @@ -1919,7 +1854,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1956,7 +1891,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2000,7 +1935,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2051,7 +1986,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2137,7 +2072,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2187,7 +2122,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2221,7 +2156,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2244,13 +2179,11 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); - app.local_terminal_notifications = false; - let mut workspace = crate::workspace::Workspace::test_new("stale"); workspace.custom_name = None; workspace.identity_cwd = "/__herdr_original__".into(); diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs index e14a933d..c8c48926 100644 --- a/src/app/api/agent_view.rs +++ b/src/app/api/agent_view.rs @@ -87,8 +87,6 @@ impl App { fn replace_agent_view_override(&mut self, view: Option) { self.state.agent_view_override = view; - self.state.agent_panel_scroll = 0; - self.state.mobile_switcher_scroll = 0; } } @@ -103,7 +101,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1ed13efc..65a65863 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -319,7 +319,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs index 0cca8cba..ba1603e2 100644 --- a/src/app/api/integrations.rs +++ b/src/app/api/integrations.rs @@ -1,9 +1,34 @@ -use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::api::schema::{ + IntegrationInfo, IntegrationInstallResult, IntegrationState, IntegrationUninstallResult, + ResponseResult, +}; use crate::app::App; use super::responses::{encode_error, encode_success}; impl App { + pub(super) fn handle_integration_list(&self, id: String) -> String { + let integrations = crate::integration::integration_recommendations() + .into_iter() + .map(|recommendation| IntegrationInfo { + target: recommendation.target, + label: recommendation.label.to_owned(), + command: recommendation.command.to_owned(), + available: recommendation.available, + state: match recommendation.state { + crate::integration::IntegrationStatusKind::NotInstalled => { + IntegrationState::NotInstalled + } + crate::integration::IntegrationStatusKind::Current => IntegrationState::Current, + crate::integration::IntegrationStatusKind::Outdated => { + IntegrationState::Outdated + } + }, + }) + .collect(); + encode_success(id, ResponseResult::IntegrationList { integrations }) + } + pub(super) fn handle_integration_install( &mut self, id: String, @@ -14,6 +39,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, @@ -34,6 +60,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 9d2756b8..943ed835 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -608,7 +608,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs index 4fc1deec..3dddccc9 100644 --- a/src/app/api/pane_graphics.rs +++ b/src/app/api/pane_graphics.rs @@ -18,17 +18,7 @@ impl App { /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not /// suspend the producer because the pane becomes visible again without a layout event. fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool { - if self.state.active != Some(ws_idx) { - return false; - } - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return false; - }; - if tab.zoomed { - tab.layout.focused() == pane_id - } else { - tab.layout.pane_ids().contains(&pane_id) - } + self.state.pane_visible_on_active_surface(ws_idx, pane_id) } pub(super) fn handle_pane_graphics_info( @@ -566,7 +556,7 @@ mod tests { let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, rx, crate::api::EventHub::default(), @@ -665,7 +655,7 @@ mod tests { } #[test] - fn monolithic_info_keeps_fast_transport_and_exact_pixels_disabled() { + fn non_persistent_info_keeps_fast_transport_and_exact_pixels_disabled() { let (mut app, pane_id) = app(); app.state.host_cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6615fa0a..f1c3a667 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1,7 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams, + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCopyMotion, + PaneCopyMotionParams, PaneCopySearchDirection, PaneCopySearchParams, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneInputSetParams, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, @@ -10,8 +11,9 @@ use crate::api::schema::{ PaneProcessInfoProcess, PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, - PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, - PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, + PaneScrollParams, PaneSelectionReadParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, + PaneTarget, PaneTextPoint, PaneTextRange, PaneZoomMode, PaneZoomParams, PaneZoomReason, PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; @@ -112,7 +114,7 @@ impl App { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -165,6 +167,304 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + runtime.set_scroll_offset_from_bottom( + usize::try_from(params.offset_from_bottom).unwrap_or(usize::MAX), + ); + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_edit_scrollback(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let is_focused = self.state.active == Some(ws_idx) + && self + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + == Some(pane_id); + if !is_focused { + return encode_error(id, "stale_pane_target", "pane is no longer focused"); + } + match self.open_focused_scrollback_in_editor() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "scrollback_editor_failed", err.to_string()), + } + } + + pub(crate) fn pane_selection_text( + &self, + params: &PaneSelectionReadParams, + ) -> Result { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return Err(("stale_content", "pane content changed".to_owned())); + } + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.anchor.row, params.anchor.col), + (params.cursor.row, params.cursor.col), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return Err(( + "selection_unavailable", + "selection text is unavailable".to_owned(), + )); + }; + if params.content_revision.is_some() && runtime.content_seq() != before { + return Err(("stale_content", "pane content changed".to_owned())); + } + Ok(text) + } + + pub(super) fn handle_pane_selection_read( + &mut self, + id: String, + params: PaneSelectionReadParams, + ) -> String { + match self.pane_selection_text(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), + } + } + + pub(super) fn handle_pane_copy_motion( + &mut self, + id: String, + params: PaneCopyMotionParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return encode_error(id, "stale_content", "pane content changed"); + } + let target = match params.motion { + PaneCopyMotion::LineEnd | PaneCopyMotion::FirstNonBlank => { + let width = runtime + .terminal_dimensions() + .map_or(1, |(cols, _)| cols.max(1)); + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.cursor.row, 0), + (params.cursor.row, width.saturating_sub(1)), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return encode_error( + id, + "copy_motion_unavailable", + "terminal row is unavailable", + ); + }; + let col = match params.motion { + PaneCopyMotion::LineEnd => { + crate::copy_mode::last_character_col(&text).unwrap_or(0) + } + PaneCopyMotion::FirstNonBlank => { + crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) + } + _ => unreachable!(), + }; + crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: col.min(width.saturating_sub(1)), + } + } + PaneCopyMotion::NextWordStart + | PaneCopyMotion::PreviousWordStart + | PaneCopyMotion::NextWordEnd + | PaneCopyMotion::NextBigWordStart + | PaneCopyMotion::PreviousBigWordStart + | PaneCopyMotion::NextBigWordEnd => { + let motion = match params.motion { + PaneCopyMotion::NextWordStart => crate::pane::TerminalWordMotion::NextStart, + PaneCopyMotion::PreviousWordStart => { + crate::pane::TerminalWordMotion::PreviousStart + } + PaneCopyMotion::NextWordEnd => crate::pane::TerminalWordMotion::NextEnd, + PaneCopyMotion::NextBigWordStart => { + crate::pane::TerminalWordMotion::NextBigStart + } + PaneCopyMotion::PreviousBigWordStart => { + crate::pane::TerminalWordMotion::PreviousBigStart + } + PaneCopyMotion::NextBigWordEnd => crate::pane::TerminalWordMotion::NextBigEnd, + _ => unreachable!(), + }; + runtime + .word_motion_target(params.cursor.row, params.cursor.col, motion) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }) + } + PaneCopyMotion::PreviousParagraph | PaneCopyMotion::NextParagraph => runtime + .paragraph_motion_target( + params.cursor.row, + if params.motion == PaneCopyMotion::PreviousParagraph { + -1 + } else { + 1 + }, + ) + .map(|target| crate::pane::TerminalTextPoint { + row: target.row, + col: params.cursor.col, + }) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }), + }; + let after = runtime.content_seq(); + if params.content_revision.is_some() && after != before { + return encode_error(id, "stale_content", "pane content changed"); + } + encode_success( + id, + ResponseResult::PaneCopyMotion { + pane_id: params.pane_id, + cursor: crate::api::schema::PaneTextPoint { + row: target.row, + col: target.col, + }, + content_revision: after, + }, + ) + } + + pub(super) fn handle_pane_copy_search( + &mut self, + id: String, + params: PaneCopySearchParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + const MAX_QUERY_BYTES: usize = 4096; + const MAX_RETURNED_MATCHES: usize = 1024; + if params.query.len() > MAX_QUERY_BYTES { + return encode_error(id, "query_too_large", "copy search query is too large"); + } + let before = runtime.content_seq(); + if before != params.content_revision || !before.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let cursor = crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }; + let previous = params.previous.map(|previous| { + ( + crate::pane::TerminalTextPoint { + row: previous.start.row, + col: previous.start.col, + }, + crate::pane::TerminalTextPoint { + row: previous.end.row, + col: previous.end.col, + }, + ) + }); + let direction = match params.direction { + PaneCopySearchDirection::Forward => crate::pane::TerminalSearchDirection::Forward, + PaneCopySearchDirection::Backward => crate::pane::TerminalSearchDirection::Backward, + }; + let result = runtime.search_text_window( + ¶ms.query, + params.query.chars().any(char::is_uppercase), + direction, + cursor, + previous, + MAX_RETURNED_MATCHES, + ); + let after = runtime.content_seq(); + if after != before || !after.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let matches = result + .matches + .into_iter() + .map(|text_match| PaneTextRange { + start: PaneTextPoint { + row: text_match.start.row, + col: text_match.start.col, + }, + end: PaneTextPoint { + row: text_match.end.row, + col: text_match.end.col, + }, + }) + .collect(); + encode_success( + id, + ResponseResult::PaneCopySearch { + pane_id: params.pane_id, + content_revision: after, + matches, + total: u64::try_from(result.total).unwrap_or(u64::MAX), + current: result.current.and_then(|index| u32::try_from(index).ok()), + current_global: result + .current_global + .and_then(|index| u64::try_from(index).ok()), + }, + ) + } + pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); @@ -175,7 +475,7 @@ impl App { self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -367,7 +667,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state @@ -950,7 +1250,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1107,7 +1407,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -1912,7 +2212,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2075,6 +2375,272 @@ mod tests { assert_eq!(scroll.viewport_rows, 5); } + #[tokio::test] + async fn api_pane_scroll_sets_and_clamps_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + let max_offset = runtime + .scroll_metrics() + .expect("scroll metrics") + .max_offset_from_bottom; + + let response = app.handle_pane_scroll( + "req".into(), + PaneScrollParams { + pane_id: public_pane_id, + offset_from_bottom: u64::MAX, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + assert_eq!( + pane.scroll.expect("scroll metrics").offset_from_bottom, + max_offset as u64 + ); + } + + #[tokio::test] + async fn api_pane_selection_read_uses_endpoint_terminal_text() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_selection_read( + "req".into(), + PaneSelectionReadParams { + pane_id: public_pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneSelection { + pane_id: public_pane_id, + text: "hello".into(), + } + ); + } + + #[tokio::test] + async fn api_copy_motion_uses_endpoint_terminal_word_semantics() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + motion: PaneCopyMotion::NextWordStart, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 6 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_paragraph_motion_preserves_the_copy_cursor_column() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"one\r\n\r\nthree", + ), + ); + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: PaneTextPoint { row: 0, col: 2 }, + motion: PaneCopyMotion::NextParagraph, + content_revision: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: PaneTextPoint { row: 1, col: 2 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_copy_search_uses_endpoint_terminal_matches_and_wraps() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta alpha", + ), + ); + + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id.clone(), + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { + pane_id, + matches, + current, + total, + current_global, + .. + } = success.result + else { + panic!("expected copy search response"); + }; + assert_eq!(pane_id, public_pane_id); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].start, PaneTextPoint { row: 0, col: 0 }); + assert_eq!(matches[1].start, PaneTextPoint { row: 0, col: 11 }); + assert_eq!(current, Some(1)); + assert_eq!(current_global, Some(1)); + assert_eq!(total, 2); + } + + #[tokio::test] + async fn api_copy_search_bounds_returned_matches_but_keeps_exact_total() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let text = "a ".repeat(1500); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 200, + 20, + 4000, + text.as_bytes(), + ), + ); + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "a".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { matches, total, .. } = success.result else { + panic!("expected copy search response"); + }; + assert_eq!(total, 1500); + assert_eq!(matches.len(), 1024); + } + + #[tokio::test] + async fn api_copy_search_rejects_stale_content_revision() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta", + ), + ); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision: 2, + previous: None, + }, + ); + assert!(response.contains("stale_content")); + } + + #[tokio::test] + async fn api_edit_scrollback_rejects_a_pane_that_is_no_longer_focused() { + let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); + app.state.active = None; + + let response = app.handle_pane_edit_scrollback( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + assert_eq!(metadata_error_code(&response), "stale_pane_target"); + assert!(app.overlay_panes.is_empty()); + } + #[tokio::test] async fn api_pane_read_reports_when_older_rows_are_omitted() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); @@ -2255,7 +2821,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2305,7 +2871,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } @@ -2415,7 +2980,11 @@ mod tests { let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -2442,46 +3011,16 @@ mod tests { assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); } - #[test] - fn api_pane_swap_unfocused_source_updates_last_pane_history() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(focused); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - - let response = app.handle_pane_swap( - "req".into(), - PaneSwapParams { - source_pane_id: Some(source_public), - target_pane_id: Some(target_public), - ..PaneSwapParams::default() - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneSwap { swap } = success.result else { - panic!("expected pane swap response"); - }; - assert!(swap.changed); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused)); - } - #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( @@ -2637,124 +3176,6 @@ mod tests { Some(&source_terminal) ); } - - #[test] - fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); - let target = app.state.workspaces[0].tabs[target_tab].root_pane; - seed_terminal_states(&mut app); - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); - - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_public, - target_pane_id: Some(target_public), - split: SplitDirection::Right, - ratio: None, - }, - focus: true, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneMove { move_result } = success.result else { - panic!("expected pane move response"); - }; - assert!(move_result.changed); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); - } - - #[tokio::test] - async fn key_release_follows_pane_moved_across_workspaces() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let source_terminal_id = app.state.workspaces[0].tabs[0] - .terminal_id(source) - .unwrap() - .clone(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>15u", - 2, - ); - app.terminal_runtimes.insert(source_terminal_id, runtime); - app.state.workspaces.push(Workspace::test_new("other")); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let source_public = app.public_pane_id(0, source).unwrap(); - let target = app.state.workspaces[1].tabs[0].root_pane; - let target_tab_id = app.public_tab_id(1, 0).unwrap(); - let target_pane_id = app.public_pane_id(1, target).unwrap(); - - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ), - )], - false, - ); - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_id, - target_pane_id: Some(target_pane_id), - split: SplitDirection::Down, - ratio: None, - }, - focus: false, - }, - ); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!(success.result, ResponseResult::PaneMove { .. })); - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ) - .with_kind(crossterm::event::KeyEventKind::Release), - )], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("forwarded release after pane move"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); @@ -3315,86 +3736,6 @@ mod tests { )); } - #[test] - fn api_pane_zoom_explicit_background_pane_updates_focus_history() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let first = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(first); - let target_public = app.public_pane_id(1, target).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(target_public.clone()), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.changed); - assert!(zoom.zoom_changed); - assert!(zoom.focus_changed); - assert_eq!(zoom.pane_id, target_public); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - assert!(app.state.workspaces[1].tabs[0].zoomed); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); - } - - #[test] - fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let _target_other = - app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[1].tabs[0].layout.focus_pane(target); - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(source_public), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.focus_changed); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - } - #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); @@ -3565,7 +3906,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3596,7 +3941,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3623,7 +3972,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( @@ -3650,7 +4003,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3688,7 +4045,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3796,7 +4157,11 @@ mod tests { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index efef83b9..8aee1c31 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -357,10 +357,6 @@ impl App { .as_ref() .and_then(|pane| pane.cwd.clone()) .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); - let selected_text = focused_pane - .as_ref() - .and_then(|pane| self.parse_pane_id(&pane.pane_id)) - .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); PluginInvocationContext { workspace_id: Some(workspace.workspace_id), workspace_label: Some(workspace.label), @@ -372,7 +368,9 @@ impl App { focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), - selected_text, + // Selection is client presentation state. Client keybindings provide + // revision-validated coordinates; API callers can provide explicit context. + selected_text: None, invocation_source: Some("api".to_string()), correlation_id: Some(correlation_id.to_string()), clicked_url: None, @@ -380,22 +378,6 @@ impl App { } } - fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option { - let selection = self.state.selection.as_ref()?; - if selection.pane_id != pane_id || !selection.is_visible() { - return None; - } - let terminal_id = self - .state - .workspaces - .iter() - .find_map(|workspace| workspace.terminal_id(pane_id))?; - self.terminal_runtimes - .get(terminal_id) - .and_then(|runtime| runtime.extract_selection(selection)) - .filter(|text| !text.is_empty()) - } - fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { self.state .workspaces diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index ddf3fba6..056315c3 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -6,11 +6,11 @@ mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ - InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, - PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, - PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, - PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, - ResponseResult, + InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams, + PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, + PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, + PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; @@ -37,7 +37,7 @@ impl App { } fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(()); } let entries = crate::persist::plugin_registry::try_load()?; @@ -49,7 +49,7 @@ impl App { &mut self, mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, ) -> std::io::Result { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } let (result, entries) = crate::persist::plugin_registry::update(|entries| { @@ -225,6 +225,7 @@ impl App { pub(crate) fn invoke_plugin_action_from_keybind( &mut self, action_id: String, + selected_text: Option, ) -> Result<(), String> { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; @@ -241,6 +242,7 @@ impl App { .map_err(|(_, message)| message)?; let mut context = self.current_plugin_context("keybinding"); context.invocation_source = Some("keybinding".to_string()); + context.selected_text = selected_text; self.start_plugin_command( &plugin, Some(action.action_id), @@ -253,6 +255,80 @@ impl App { .map_err(|(_, message)| message) } + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "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"); + } + 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"); + }; + let current_offset = runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64); + if params + .offset_from_bottom + .is_some_and(|expected| current_offset != Some(expected)) + { + return encode_error( + id, + "stale_content", + "pane viewport changed before link activation", + ); + } + let content_revision = runtime.content_seq(); + if content_revision % 2 != 0 + || params + .content_revision + .is_some_and(|expected| expected != content_revision) + { + return encode_error( + id, + "stale_content", + "pane content changed before link activation", + ); + } + let url = self.state.url_at_pane_surface_cell( + &self.terminal_runtimes, + ws_idx, + pane_id, + params.viewport_row, + params.col, + ); + if runtime.content_seq() != content_revision + || runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64) + != current_offset + { + return encode_error( + id, + "stale_content", + "pane content or viewport changed during link activation", + ); + } + let handled = match url.as_deref() { + Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { + Ok(handled) => handled, + Err(err) => { + tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + false + } + }, + None => false, + }; + encode_success(id, ResponseResult::PaneLinkActivated { url, handled }) + } + pub(crate) fn invoke_plugin_link_handler_for_url( &mut self, url: &str, @@ -392,13 +468,8 @@ impl App { "width and height are only supported when placement is popup", ); } - if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal - { - return encode_error( - id, - "ui_busy", - "popup panes can only open from the normal workspace view", - ); + if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() { + return encode_error(id, "ui_busy", "a popup pane is already open"); } match placement { PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { @@ -457,7 +528,7 @@ impl App { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; @@ -716,7 +787,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"] let _ = std::fs::remove_dir_all(root); } - #[test] - fn plugin_pane_open_popup_preserves_existing_ui_modes() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - let root_pane = app.state.workspaces[0].tabs[0].root_pane; - let root = unique_temp_path("plugin-popup-ui-busy"); - write_manifest(&root); - link_manifest(&mut app, &root); - - let open_popup = |app: &mut App, id: &str| { - app.handle_api_request(Request { - id: id.into(), - method: Method::PluginPaneOpen(PluginPaneOpenParams { - plugin_id: "example.worktree-bootstrap".into(), - entrypoint: "board".into(), - placement: Some(PluginPanePlacement::Popup), - width: None, - height: None, - workspace_id: None, - target_pane_id: None, - direction: None, - cwd: None, - focus: true, - env: std::collections::HashMap::new(), - }), - }) - }; - - app.state.mode = crate::app::Mode::Settings; - app.state.settings.original_theme = Some("settings-theme".into()); - let settings_response = open_popup(&mut app, "settings-popup"); - let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); - assert_eq!(settings_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Settings); - assert_eq!( - app.state.settings.original_theme.as_deref(), - Some("settings-theme") - ); - assert!(app.state.popup_pane.is_none()); - - let copy_mode = crate::app::state::CopyModeState { - pane_id: root_pane, - cursor_row: 2, - cursor_col: 3, - entry_offset_from_bottom: 4, - selection: None, - search: crate::app::state::CopyModeSearchState::default(), - }; - app.state.mode = crate::app::Mode::Copy; - app.state.copy_mode = Some(copy_mode.clone()); - let copy_response = open_popup(&mut app, "copy-popup"); - let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); - assert_eq!(copy_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert!(app.state.popup_pane.is_none()); - - let _ = std::fs::remove_dir_all(root); - } - #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] write_manifest_content(&root, &manifest); link_manifest(&mut app, &root); - let open = app.handle_api_request(Request { - id: "pane-open-popup".into(), + let popup_request = |id: &str| Request { + id: id.into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.popup".into(), entrypoint: "board".into(), @@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] focus: true, env: std::collections::HashMap::new(), }), - }); + }; + app.state.mode = crate::app::Mode::Navigate; + let open = app.handle_api_request(popup_request("pane-open-popup")); assert_eq!(response_result(&open), ResponseResult::Ok {}); + let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate")); + let duplicate: crate::api::schema::ErrorResponse = + serde_json::from_str(&duplicate).unwrap(); + assert_eq!(duplicate.error.code, "ui_busy"); assert_eq!( read_capture_when_ready(&env_capture, || { app.drain_internal_events(); @@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] .unwrap(); let mut app = test_app(); - app.no_session = false; + app.policy.persist_plugin_registry = true; let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; @@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] make_stale(&mut app); assert!(app - .invoke_plugin_action_from_keybind("bootstrap".into()) + .invoke_plugin_action_from_keybind("bootstrap".into(), None) .unwrap_err() .contains("disabled")); @@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG } #[tokio::test] - async fn current_plugin_context_includes_selected_text_for_focused_pane() { + async fn current_plugin_context_leaves_client_owned_selection_empty() { let mut app = test_app(); let workspace = crate::workspace::Workspace::test_new("plugin-selection"); let pane_id = workspace.tabs[0].root_pane; @@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG terminal_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), ); - app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None)); - let context = app.current_plugin_context("selection-test"); - assert_eq!(context.selected_text.as_deref(), Some("hello")); + assert_eq!(context.selected_text, None); } #[cfg(unix)] diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs index 7c871c37..3cfa1a5c 100644 --- a/src/app/api/responses.rs +++ b/src/app/api/responses.rs @@ -1,10 +1,10 @@ use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; -pub(super) fn encode_success(id: String, result: ResponseResult) -> String { +pub(crate) fn encode_success(id: String, result: ResponseResult) -> String { serde_json::to_string(&SuccessResponse { id, result }).unwrap() } -pub(super) fn encode_error(id: String, code: &str, message: impl Into) -> String { +pub(crate) fn encode_error(id: String, code: &str, message: impl Into) -> String { encode_error_body( id, ErrorBody { diff --git a/src/app/api/session.rs b/src/app/api/session.rs index b2238faa..a7d14b3d 100644 --- a/src/app/api/session.rs +++ b/src/app/api/session.rs @@ -13,7 +13,7 @@ impl App { ) } - fn session_snapshot(&self) -> SessionSnapshot { + pub(crate) fn session_snapshot(&self) -> SessionSnapshot { let focused_workspace_id = self .state .active @@ -66,7 +66,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 42d2b029..c1bd86e9 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -157,13 +157,6 @@ impl App { }; tab.set_custom_name(params.label.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); - if self.state.active == Some(ws_idx) { - // Reflow the tab bar so the new label width takes effect immediately. - // The tab bar renders into cached hit areas; without this refresh the - // old geometry lingers until the next refresh (e.g. a tab switch), - // leaving the visible label stale. Mirrors handle_tab_move. - self.state.refresh_tab_bar_view(); - } self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabRenamed, @@ -206,10 +199,6 @@ impl App { let tabs = self.tab_list_info(ws_idx); if moved { self.schedule_session_save(); - if self.state.active == Some(ws_idx) { - self.state.tab_scroll_follow_active = true; - self.state.refresh_tab_bar_view(); - } self.emit_event(EventEnvelope { event: EventKind::TabMoved, data: EventData::TabMoved { @@ -337,7 +326,13 @@ mod tests { fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("tabs")]; app.state.active = Some(0); app.state.selected = 0; @@ -384,7 +379,13 @@ mod tests { fn api_tab_move_reorders_tabs_in_target_workspace() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); let mut workspace = Workspace::test_new("tabs"); workspace.test_add_tab(Some("two")); workspace.test_add_tab(Some("three")); @@ -424,42 +425,17 @@ mod tests { })); } - #[test] - fn api_tab_rename_reflows_active_tab_bar() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); - let workspace = Workspace::test_new("tabs"); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.view.tab_bar_rect = ratatui::layout::Rect::new(0, 0, 60, 1); - app.state.refresh_tab_bar_view(); - - let tab_id = app.public_tab_id(0, 0).unwrap(); - let width_before = app.state.view.tab_hit_areas[0].width; - - app.handle_tab_rename( - "req".into(), - TabRenameParams { - tab_id, - label: "a much longer custom tab label".into(), - }, - ); - - let width_after = app.state.view.tab_hit_areas[0].width; - assert!( - width_after > width_before, - "tab bar should reflow to the new label width immediately: \ - before={width_before}, after={width_after}" - ); - } - #[tokio::test] async fn tab_create_follows_cached_focused_pane_cwd_without_runtime() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.default_shell = exiting_test_command().into(); app.state.shell_mode = ShellModeConfig::NonLogin; let workspace = Workspace::test_new("tabs"); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d9e689e3..9b125e57 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -41,12 +41,25 @@ impl App { id: String, params: WorkspaceCreateParams, ) -> String { + let source_workspace_index = if params.cwd.is_some() { + None + } else { + match params.source_workspace_id.as_deref() { + Some(workspace_id) => match self + .parse_workspace_id(workspace_id) + .filter(|index| self.state.workspaces.get(*index).is_some()) + { + Some(index) => Some(index), + None => return workspace_not_found(id, workspace_id), + }, + None => self.workspace_creation_source(), + } + }; let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - self.resolve_new_terminal_cwd(follow_cwd) + source_workspace_index.map_or_else( + || self.resolve_new_terminal_cwd(None), + |index| self.resolved_new_workspace_cwd_from(index), + ) }); let extra_env = match super::env::normalize_launch_env(params.env) { Ok(env) => env, @@ -360,7 +373,11 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + use crate::{ + api::schema::{ErrorResponse, SuccessResponse}, + config::Config, + workspace::Workspace, + }; // `new_cwd = follow` must anchor on the focused pane for every creation // surface. Splits and tabs already do; a new workspace must follow the @@ -373,7 +390,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -419,6 +436,7 @@ mod tests { let response = app.handle_workspace_create( "req".into(), WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: false, label: None, @@ -444,11 +462,99 @@ mod tests { let _ = std::fs::remove_dir_all(&focused_cwd); } + #[tokio::test] + async fn workspace_create_uses_explicit_source_workspace() { + use super::super::test_support::{exiting_test_command, shutdown_test_runtimes}; + use crate::config::ShellModeConfig; + + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.default_shell = exiting_test_command().into(); + app.state.shell_mode = ShellModeConfig::NonLogin; + app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("source")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + shutdown_test_runtimes(&mut app); + + let source_cwd = + std::env::temp_dir().join(format!("herdr-ws-explicit-source-{}", std::process::id())); + std::fs::create_dir_all(&source_cwd).unwrap(); + let pane_id = app.state.workspaces[1].focused_pane_id().unwrap(); + let terminal_id = app.state.workspaces[1] + .terminal_id(pane_id) + .cloned() + .unwrap(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = source_cwd.clone(); + let source_workspace_id = app.public_workspace_id(1); + + let response = app.handle_workspace_create( + "req".into(), + WorkspaceCreateParams { + source_workspace_id: Some(source_workspace_id), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[2].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + + let invalid = app.handle_workspace_create( + "invalid".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let error: ErrorResponse = serde_json::from_str(&invalid).unwrap(); + assert_eq!(error.error.code, "workspace_not_found"); + + let captured = app.handle_workspace_create( + "captured".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: Some(source_cwd.display().to_string()), + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&captured).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[3].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&source_cwd); + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -604,7 +710,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "parent"); } @@ -613,7 +718,13 @@ mod tests { fn api_workspace_close_event_includes_final_worktree_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = app_with_linked_worktree().state.workspaces; let workspace_id = app.state.workspaces[0].id.clone(); @@ -647,7 +758,13 @@ mod tests { fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); @@ -699,7 +816,13 @@ mod tests { fn workspace_token_ttl_expires_through_runtime_and_emits_update() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); let response = app.handle_workspace_report_metadata( @@ -731,7 +854,13 @@ mod tests { fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("one"), Workspace::test_new("two"), @@ -773,7 +902,13 @@ mod tests { fn api_workspace_move_block_reorders_atomically() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("child"), Workspace::test_new("normal"), @@ -826,7 +961,13 @@ mod tests { fn api_workspace_move_noop_does_not_emit_event() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; let moved_id = app.public_workspace_id(0); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 4a3e23a8..cf4ca3f9 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -638,14 +638,6 @@ impl App { }); } - #[cfg(test)] - pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) { - let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { - return; - }; - self.emit_worktree_opened_event(ws_idx, worktree, already_open); - } - fn emit_worktree_opened_event( &mut self, ws_idx: usize, @@ -802,7 +794,13 @@ mod tests { fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - App::new(&Config::default(), true, None, api_rx, event_hub) + App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ) } #[cfg(windows)] @@ -936,6 +934,7 @@ mod tests { assert_eq!(tab.workspace_id, workspace.workspace_id); assert_eq!(root_pane.workspace_id, workspace.workspace_id); assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create")); + assert!(Path::new(&worktree.path).starts_with(&worktree_root)); assert!(Path::new(&worktree.path).join("README.md").exists()); assert_eq!(app.state.workspaces.len(), 2); assert!( diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 4755b38d..110f00c9 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -377,12 +377,6 @@ impl App { self.pending_api_worktree_creates.remove(&checkout_key); if let Err(err) = result.result { - if let Some(create) = &mut self.state.worktree_create { - if create.checkout_path == result.path { - create.creating = false; - create.error = Some(err.clone()); - } - } Self::send_api_response( api.respond_to, encode_error(api.id, "worktree_create_failed", err), @@ -438,17 +432,6 @@ impl App { ws.set_custom_name(label); } } - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.checkout_path == result.path) - { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = crate::app::Mode::Terminal; - } self.state.mark_session_dirty(); if created_workspace { self.emit_workspace_open_events(ws_idx); @@ -520,17 +503,6 @@ impl App { } else { "worktree_remove_failed" }; - if let Some(remove) = &mut self.state.worktree_remove { - if remove.workspace_id == result.workspace_id && remove.path == result.path { - remove.removing = false; - if code == "dirty_worktree_requires_force" && !remove.force_confirmation { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message.clone()); - } - } - } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -592,16 +564,6 @@ impl App { worktree, result.forced, ); - if self.state.worktree_remove.as_ref().is_some_and(|remove| { - remove.workspace_id == result.workspace_id && remove.path == result.path - }) { - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - crate::app::Mode::Terminal - } else { - crate::app::Mode::Navigate - }; - } let response = encode_success( api.id, ResponseResult::WorktreeRemoved { diff --git a/src/app/config_io.rs b/src/app/config_io.rs deleted file mode 100644 index 9f7445b9..00000000 --- a/src/app/config_io.rs +++ /dev/null @@ -1,128 +0,0 @@ -use super::App; - -impl App { - pub(super) fn update_config_file(&mut self, error_context: &str, update: F) -> bool - where - F: FnOnce(&str) -> String, - { - #[cfg(test)] - if std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR).is_none() { - return false; - } - - let path = crate::config::config_path(); - if let Some(parent) = path.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - crate::logging::config_write_failed(&path, error_context, &err.to_string()); - self.state.config_diagnostic = - Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return false; - } - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let new_content = update(&content); - if let Err(err) = std::fs::write(&path, new_content) { - crate::logging::config_write_failed(&path, error_context, &err.to_string()); - self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return false; - } - - true - } - - pub(super) fn mark_onboarding_complete(&mut self) { - self.update_config_file("onboarding setting", |content| { - crate::config::upsert_top_level_bool(content, "onboarding", false) - }); - } - - pub(super) fn save_theme(&mut self, name: &str) { - if self.update_config_file("theme", |content| { - let content = crate::config::upsert_section_value( - content, - "theme", - "name", - &format!("\"{name}\""), - ); - crate::config::upsert_section_bool(&content, "theme", "auto_switch", false) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) { - if self.update_config_file("status indicators", |content| { - crate::config::upsert_section_value( - content, - "ui", - "status_indicators", - &format!("\"{}\"", style.as_str()), - ) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_sound(&mut self, enabled: bool) { - if self.update_config_file("sound setting", |content| { - crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { - let value = match delivery { - crate::config::ToastDelivery::Off => "\"off\"", - crate::config::ToastDelivery::Herdr => "\"herdr\"", - crate::config::ToastDelivery::Terminal => "\"terminal\"", - crate::config::ToastDelivery::System => "\"system\"", - }; - if self.update_config_file("toast setting", |content| { - let content = - crate::config::upsert_section_value(content, "ui.toast", "delivery", value); - crate::config::remove_section_key(&content, "ui.toast", "enabled") - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_agent_border_labels(&mut self, enabled: bool) { - if self.update_config_file("agent border labels", |content| { - crate::config::upsert_section_bool( - content, - "ui", - "show_agent_labels_on_pane_borders", - enabled, - ) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) { - let value = match sort { - crate::app::state::AgentPanelSort::Spaces => { - crate::config::AgentPanelSortConfig::Spaces.as_str() - } - crate::app::state::AgentPanelSort::Priority => { - crate::config::AgentPanelSortConfig::Priority.as_str() - } - }; - if self.update_config_file("agent panel sort", |content| { - crate::config::upsert_section_value( - content, - "ui", - "agent_panel_sort", - &format!("\"{value}\""), - ) - }) { - self.apply_config_from_disk(false); - } - } -} diff --git a/src/app/creation.rs b/src/app/creation.rs index d531e733..fbe61902 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -1,13 +1,10 @@ use std::path::PathBuf; -use crate::api::schema::{EventData, EventEnvelope, EventKind}; -#[cfg(test)] -use tracing::error; - use super::{ api_helpers::{pane_agent_status, tab_attention_priority}, App, Mode, }; +use crate::api::schema::{EventData, EventEnvelope, EventKind}; use crate::{config::NewTerminalCwdConfig, workspace::Workspace}; pub(crate) fn resolve_new_terminal_cwd( @@ -81,6 +78,13 @@ impl App { resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd) } + pub(crate) fn resolved_new_workspace_cwd_from(&self, ws_idx: usize) -> PathBuf { + let follow_cwd = self + .focused_pane_cwd_in_workspace(ws_idx) + .or_else(|| self.seed_cwd_from_workspace(ws_idx)); + self.resolve_new_terminal_cwd(follow_cwd) + } + pub(super) fn workspace_creation_source(&self) -> Option { if self.state.mode == Mode::Navigate && self.state.workspaces.get(self.state.selected).is_some() @@ -96,128 +100,6 @@ impl App { }) } - pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) { - if self.state.prompt_new_workspace_name { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let cwd = self.resolve_new_terminal_cwd(follow_cwd); - super::input::open_new_workspace_dialog(&mut self.state, cwd); - return; - } - - self.runtime_workspace_create( - request_id, - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - /// Create a workspace with a real PTY (needs event_tx). - #[cfg(test)] - pub(crate) fn create_workspace(&mut self) { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - if let Err(e) = self.create_workspace_with_events(initial_cwd, true) { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } - } - - #[cfg(test)] - pub(crate) fn create_tab(&mut self) { - let custom_name = self.state.requested_new_tab_name.take(); - let active_before = self.state.active; - let follow_cwd = self.state.active.and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - match self.create_tab_with_options(initial_cwd, true) { - Ok(created_idx) => { - let created_workspace = active_before.is_none(); - let ws_idx = if created_workspace { - Some(created_idx) - } else { - self.state.active - }; - let tab_idx = if created_workspace { 0 } else { created_idx }; - if let Some(name) = custom_name { - if let Some(ws) = - ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) - { - if let Some(tab) = ws.tabs.get_mut(tab_idx) { - tab.set_custom_name(name); - } - self.schedule_session_save(); - } - } - if let Some(ws_idx) = ws_idx { - if created_workspace { - self.emit_workspace_open_events(ws_idx); - } else { - self.emit_tab_created_events(ws_idx, tab_idx); - } - } - } - Err(e) => { - error!(err = %e, "failed to create tab"); - } - } - } - - #[cfg(test)] - pub(super) fn create_tab_with_options( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result { - let Some(ws_idx) = self.state.active else { - return self.create_workspace_with_options(initial_cwd, focus); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let ws = &mut self.state.workspaces[ws_idx]; - let (idx, terminal, runtime) = ws.create_tab( - rows, - cols, - initial_cwd, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), - Vec::new(), - )?; - let root_pane = ws.tabs[idx].root_pane; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.remove_alias_shadowed_by_new_pane(root_pane); - if focus { - self.state.switch_workspace_tab(ws_idx, idx); - self.state.mode = Mode::Terminal; - } - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, idx) - .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1)); - let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw(); - crate::logging::tab_created(&workspace_id, &tab_id, root_pane); - self.schedule_session_save(); - Ok(idx) - } - pub(crate) fn create_workspace_with_options( &mut self, initial_cwd: PathBuf, @@ -226,17 +108,6 @@ impl App { self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new()) } - #[cfg(test)] - pub(crate) fn create_workspace_with_events( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result<()> { - let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?; - self.emit_workspace_open_events(ws_idx); - Ok(()) - } - pub(crate) fn create_workspace_with_launch_env( &mut self, initial_cwd: PathBuf, diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs new file mode 100644 index 00000000..a2245884 --- /dev/null +++ b/src/app/custom_commands.rs @@ -0,0 +1,782 @@ +use std::fs; +use std::io::{self, Write}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ratatui::layout::Direction; + +use super::{App, Mode}; + +static NEXT_COMMAND_NAMESPACE: AtomicU64 = AtomicU64::new(1); + +pub(super) fn new_command_namespace() -> String { + let counter = NEXT_COMMAND_NAMESPACE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{}-{nanos}-{counter}", std::process::id()) +} + +#[derive(Debug)] +pub(super) struct EndpointCommandRegistry { + entries: Vec, +} + +#[derive(Debug)] +struct EndpointCommand { + id: String, + binding: crate::config::CustomCommandKeybind, + action: crate::protocol::ClientShellCommandAction, +} + +impl EndpointCommandRegistry { + pub(super) fn new(bindings: &[crate::config::CustomCommandKeybind]) -> Self { + let namespace = new_command_namespace(); + let entries = bindings + .iter() + .enumerate() + .map(|(index, binding)| EndpointCommand { + action: binding.action.into(), + id: format!("cmd_{namespace}_{index}"), + binding: binding.clone(), + }) + .collect(); + Self { entries } + } +} + +impl App { + pub(crate) fn client_shell_keybindings_profile(&self) -> Option<&str> { + self.client_shell_keybindings_profile.as_deref() + } + + pub(crate) fn client_shell_command_manifest(&self) -> Vec { + self.endpoint_commands + .entries + .iter() + .map(|entry| crate::protocol::ClientShellCommand { + command_id: entry.id.clone(), + binding_label: entry.binding.label.clone(), + binding_labels: entry.binding.bindings.labels(), + action: entry.action, + description: entry.binding.description.clone(), + }) + .collect() + } + + pub(crate) fn resolve_client_shell_command( + &self, + id: &str, + ) -> Option { + self.endpoint_commands + .entries + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.binding.clone()) + } + + pub(crate) fn handle_command_invoke( + &mut self, + id: String, + params: crate::api::schema::CommandInvokeParams, + ) -> String { + let Some(binding) = self.resolve_client_shell_command(¶ms.command_id) else { + return crate::app::api::responses::encode_error( + id, + "command_not_found", + "custom command manifest is stale; reload configuration", + ); + }; + if let Err((code, message)) = self.focus_client_shell_command_target(¶ms) { + return crate::app::api::responses::encode_error(id, code, message); + } + let selected_text = if binding.action == crate::config::CustomCommandAction::PluginAction { + let Some(selection) = params.selection.as_ref() else { + return self.execute_custom_command_response(id, &binding, None); + }; + if params.pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return crate::app::api::responses::encode_error( + id, + "command_target_mismatch", + "command selection does not belong to the requested pane", + ); + } + match self.pane_selection_text(selection) { + Ok(text) => Some(text), + Err((code, message)) => { + return crate::app::api::responses::encode_error(id, code, message); + } + } + } else { + None + }; + self.execute_custom_command_response(id, &binding, selected_text) + } + + fn execute_custom_command_response( + &mut self, + id: String, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option, + ) -> String { + match self.execute_custom_command_binding(binding, selected_text) { + Ok(()) => crate::app::api::responses::encode_success( + id, + crate::api::schema::ResponseResult::Ok {}, + ), + Err(error) => { + crate::app::api::responses::encode_error(id, "command_failed", error.to_string()) + } + } + } + + fn focus_client_shell_command_target( + &mut self, + params: &crate::api::schema::CommandInvokeParams, + ) -> Result<(), (&'static str, String)> { + if let Some(pane_id) = params.pane_id.as_deref() { + let Some((workspace_index, pane)) = self.parse_pane_id(pane_id) else { + return Err(("pane_not_found", format!("pane not found: {pane_id}"))); + }; + let Some(tab_index) = + self.state.workspaces[workspace_index].find_tab_index_for_pane(pane) + else { + return Err(("pane_not_found", format!("pane not found: {pane_id}"))); + }; + self.validate_client_shell_command_parent_ids(params, workspace_index, tab_index)?; + self.state.focus_pane_in_workspace(workspace_index, pane); + return Ok(()); + } + + if let Some(tab_id) = params.tab_id.as_deref() { + let Some((workspace_index, tab_index)) = self.parse_tab_id(tab_id) else { + return Err(("tab_not_found", format!("tab not found: {tab_id}"))); + }; + self.validate_client_shell_command_workspace_id(params, workspace_index)?; + self.state.switch_workspace_tab(workspace_index, tab_index); + return Ok(()); + } + + if let Some(workspace_id) = params.workspace_id.as_deref() { + let Some(workspace_index) = self.parse_workspace_id(workspace_id) else { + return Err(( + "workspace_not_found", + format!("workspace not found: {workspace_id}"), + )); + }; + self.state.switch_workspace(workspace_index); + } + Ok(()) + } + + fn validate_client_shell_command_parent_ids( + &self, + params: &crate::api::schema::CommandInvokeParams, + workspace_index: usize, + tab_index: usize, + ) -> Result<(), (&'static str, String)> { + self.validate_client_shell_command_workspace_id(params, workspace_index)?; + if let Some(tab_id) = params.tab_id.as_deref() { + let actual = self + .public_tab_id(workspace_index, tab_index) + .unwrap_or_default(); + if tab_id != actual { + return Err(( + "command_target_mismatch", + "command pane does not belong to the requested tab".to_owned(), + )); + } + } + Ok(()) + } + + fn validate_client_shell_command_workspace_id( + &self, + params: &crate::api::schema::CommandInvokeParams, + workspace_index: usize, + ) -> Result<(), (&'static str, String)> { + if params + .workspace_id + .as_deref() + .is_some_and(|workspace_id| workspace_id != self.public_workspace_id(workspace_index)) + { + return Err(( + "command_target_mismatch", + "command target does not belong to the requested workspace".to_owned(), + )); + } + Ok(()) + } + pub(crate) fn execute_custom_command_binding( + &mut self, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option, + ) -> io::Result<()> { + match binding.action { + crate::config::CustomCommandAction::Shell => self.spawn_custom_command(binding), + crate::config::CustomCommandAction::Pane => { + self.spawn_pane_command(&binding.command, Vec::new()) + } + crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(binding), + crate::config::CustomCommandAction::PluginAction => self + .invoke_plugin_action_from_keybind(binding.command.clone(), selected_text) + .map_err(io::Error::other), + } + } + + fn spawn_custom_popup_command( + &mut self, + binding: &crate::config::CustomCommandKeybind, + ) -> io::Result<()> { + self.spawn_popup_shell_command( + &binding.command, + None, + self.custom_command_env().0, + crate::app::popup::PopupGeometry { + width: binding.width, + height: binding.height, + }, + ) + } + + pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { + let mut env = vec![( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + + let mut cwd = None; + if let Some(ws_idx) = self.state.active { + env.push(( + "HERDR_ACTIVE_WORKSPACE_ID".to_string(), + self.public_workspace_id(ws_idx), + )); + if let Some(workspace) = self.state.workspaces.get(ws_idx) { + let tab_idx = workspace.active_tab_index(); + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); + } + if let Some(pane_id) = workspace.focused_pane_id() { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); + } + if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { + tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) + }) { + env.push(( + "HERDR_ACTIVE_PANE_CWD".to_string(), + pane_cwd.display().to_string(), + )); + if pane_cwd.is_dir() { + cwd = Some(pane_cwd); + } + } + } + } + } + (env, cwd) + } + + fn spawn_custom_command( + &mut self, + binding: &crate::config::CustomCommandKeybind, + ) -> std::io::Result<()> { + let mut command = crate::platform::detached_custom_command_process(&binding.command); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let (env, cwd) = self.custom_command_env(); + command.envs(env); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let child = command.spawn()?; + self.detached_process_children.push(child); + Ok(()) + } + + pub(crate) fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { + let ws_idx = self + .state + .active + .ok_or_else(|| std::io::Error::other("no active workspace"))?; + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let pane_id = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let scrollback = self + .state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? + .recent_unwrapped_text_snapshot(usize::MAX) + .text; + + let path = write_scrollback_temp_file(&scrollback)?; + + let argv = match crate::platform::scrollback_editor_argv(&path) { + Ok(argv) => argv, + Err(err) => { + let _ = fs::remove_file(&path); + return Err(err); + } + }; + let (env, _) = self.custom_command_env(); + let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { + Ok((_, new_pane)) => new_pane, + Err(err) => { + let _ = fs::remove_file(&path); + return Err(err); + } + }; + let terminal_id = new_pane.terminal.id.clone(); + self.terminal_runtimes + .insert(terminal_id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state.terminals.insert(terminal_id, new_pane.terminal); + + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::Finished, + title: "opened scrollback".to_string(), + context: format!("focused pane {public_pane_id}"), + position: None, + target: None, + }); + } + Ok(()) + } + + fn spawn_pane_command( + &mut self, + command: &str, + temp_files: Vec, + ) -> std::io::Result<()> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let previous_focus_target = self.state.current_pane_focus_target(); + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + let (env, _) = self.custom_command_env(); + + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let tab_idx = ws.active_tab_index(); + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let cwd = ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.terminal_runtimes, + ) + }); + let new_pane = ws.split_focused_command( + Direction::Horizontal, + new_rows, + new_cols, + cwd, + command, + env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + self.state.host_terminal_appearance, + )?; + let new_pane_id = new_pane.pane_id; + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id: new_pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } + ws.active_tab_mut() + .expect("workspace must have an active tab") + .layout + .focus_pane(new_pane_id); + ws.active_tab_mut() + .expect("workspace must have an active tab") + .zoomed = true; + self.overlay_panes.insert( + new_pane_id, + super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + temp_files, + }, + ); + self.state.remove_alias_shadowed_by_new_pane(new_pane_id); + self.state.mode = Mode::Terminal; + Ok(()) + } + + pub(crate) fn spawn_overlay_argv_command( + &mut self, + argv: &[String], + cwd: Option, + extra_env: Vec<(String, String)>, + temp_files: Vec, + ) -> std::io::Result<(usize, crate::workspace::NewPane)> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let previous_focus_target = self.state.current_pane_focus_target(); + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let cwd = cwd.or_else(|| { + ws.active_tab().and_then(|tab| { + tab.cwd_for_pane( + previous_focus, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + + let (tab_idx, new_pane, workspace_id) = { + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let result = ws.split_pane_argv_command( + previous_focus, + Direction::Horizontal, + new_rows, + new_cols, + cwd, + argv, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + self.state.host_terminal_appearance, + true, + ); + let (tab_idx, new_pane) = match result { + Some(Ok(result)) => result, + Some(Err(err)) => return Err(err), + None => return Err(std::io::Error::other("focused pane disappeared")), + }; + ws.tabs + .get_mut(tab_idx) + .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? + .zoomed = true; + self.overlay_panes.insert( + new_pane.pane_id, + super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + temp_files, + }, + ); + (tab_idx, new_pane, ws.id.clone()) + }; + + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id, + pane_id: new_pane.pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state.mode = Mode::Terminal; + Ok((ws_idx, new_pane)) + } +} + +fn write_scrollback_temp_file(content: &str) -> io::Result { + let mut last_collision = None; + for attempt in 0..16 { + let path = unique_scrollback_path(attempt); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + match options.open(&path) { + Ok(mut file) => { + file.write_all(content.as_bytes())?; + return Ok(path); + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + last_collision = Some(err); + } + Err(err) => return Err(err), + } + } + + Err(last_collision.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create unique scrollback temp file", + ) + })) +} + +fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "herdr-scrollback-{}-{nanos}-{attempt}.txt", + std::process::id() + )) +} + +#[cfg(test)] +mod tests { + fn test_app() -> crate::app::App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + fn binding(action: crate::config::CustomCommandAction) -> crate::config::CustomCommandKeybind { + crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action, + description: Some("safe description".into()), + width: None, + height: None, + } + } + + fn install(app: &mut crate::app::App, binding: crate::config::CustomCommandKeybind) { + app.endpoint_commands = super::EndpointCommandRegistry::new(&[binding]); + } + + #[test] + fn manifest_exposes_opaque_ids_without_command_text() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let manifest = app.client_shell_command_manifest(); + assert_eq!(manifest.len(), 1); + assert_eq!(manifest[0].binding_label, "prefix+z"); + assert_eq!(manifest[0].binding_labels, ["prefix+z"]); + assert_eq!(manifest[0].description.as_deref(), Some("safe description")); + assert!(!format!("{:?}", manifest).contains("secret-command")); + assert_eq!( + app.resolve_client_shell_command(&manifest[0].command_id) + .map(|binding| binding.command), + Some("secret-command --token hidden".into()) + ); + } + + #[test] + fn stale_command_id_is_rejected_after_definition_changes() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let old_id = app.client_shell_command_manifest()[0].command_id.clone(); + let mut replacement = binding(crate::config::CustomCommandAction::Shell); + replacement.command = "replacement-command".into(); + install(&mut app, replacement); + + let response = app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id: old_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "command_not_found"); + } + + #[test] + fn foreground_keybinding_projection_cannot_remap_command_ids() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + let mut foreground_binding = binding(crate::config::CustomCommandAction::Shell); + foreground_binding.command = "different-foreground-command".into(); + app.state.keybinds.custom_commands = vec![foreground_binding]; + + assert_eq!( + app.resolve_client_shell_command(&command_id) + .map(|binding| binding.command), + Some("secret-command --token hidden".into()) + ); + } + + #[test] + fn command_ids_do_not_alias_across_endpoint_restarts() { + let mut old_app = test_app(); + install( + &mut old_app, + binding(crate::config::CustomCommandAction::Shell), + ); + let old_id = old_app.client_shell_command_manifest()[0] + .command_id + .clone(); + + let mut replacement_app = test_app(); + let mut replacement = binding(crate::config::CustomCommandAction::Shell); + replacement.command = "replacement-command".into(); + install(&mut replacement_app, replacement); + assert_ne!( + replacement_app.client_shell_command_manifest()[0].command_id, + old_id + ); + let response = replacement_app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id: old_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "command_not_found"); + } + + #[tokio::test] + async fn plugin_command_rejects_stale_client_selection_before_invocation() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("plugin-selection"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"selected text\n"), + ); + let mut plugin = binding(crate::config::CustomCommandAction::PluginAction); + plugin.command = "missing.plugin-action".into(); + install(&mut app, plugin); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + let workspace_id = app.public_workspace_id(0); + let tab_id = app.public_tab_id(0, 0).unwrap(); + let pane_id = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_command_invoke( + "request-selection".into(), + crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(pane_id.clone()), + selection: Some(crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 7 }, + content_revision: Some(u64::MAX), + }), + }, + ); + + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "stale_content"); + } + + #[cfg(unix)] + #[test] + fn shell_command_invocation_executes_endpoint_owned_definition() { + let mut app = test_app(); + let path = std::path::PathBuf::from(format!( + "/var/tmp/herdr-command-invoke-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&path); + let mut command = binding(crate::config::CustomCommandAction::Shell); + command.command = format!("printf invoked > {}", path.display()); + install(&mut app, command); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + + let response = app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let success: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, crate::api::schema::ResponseResult::Ok {}); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(std::fs::read_to_string(&path).unwrap(), "invoked"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn popup_commands_are_advertised_without_exposing_the_command_text() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Popup)); + + let manifest = app.client_shell_command_manifest(); + assert_eq!(manifest.len(), 1); + assert_eq!( + manifest[0].action, + crate::protocol::ClientShellCommandAction::Popup + ); + assert!(!format!("{manifest:?}").contains("secret-command")); + } +} diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index c266add4..fd512d55 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -529,7 +529,7 @@ mod tests { fn test_app(config: &crate::config::Config) -> super::super::App { super::super::App::new( config, - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), diff --git a/src/app/ids.rs b/src/app/ids.rs index c2fc0a4d..c33a5fff 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -24,7 +24,7 @@ impl App { )) } - pub(super) fn public_pane_id( + pub(crate) fn public_pane_id( &self, ws_idx: usize, pane_id: crate::layout::PaneId, diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs deleted file mode 100644 index 095b944d..00000000 --- a/src/app/input/clipboard.rs +++ /dev/null @@ -1,370 +0,0 @@ -use crossterm::event::{KeyCode, KeyModifiers}; - -use crate::{ - app::{App, InputSourceId}, - input::TerminalKey, -}; - -use super::{ConsumedInputLease, InputLeaseKey}; - -fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { - matches!(key.code, KeyCode::Char('c' | 'C')) - && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) -} - -impl App { - pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool { - let Some(content) = self.state.request_clipboard_write.take() else { - return false; - }; - if self - .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) - .is_err() - { - tracing::warn!("failed to queue clipboard write event"); - } - true - } - - pub(super) fn try_copy_retained_selection( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> bool { - if self.state.copy_on_select - || !is_retained_selection_copy_key(&key) - || !self - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible) - { - return false; - } - - self.state.copy_selection(&self.terminal_runtimes); - self.selection_autoscroll_deadline = None; - if !self.dispatch_pending_clipboard_write() { - return false; - } - - self.input_leases.insert_consumed( - InputLeaseKey::new(source_id, &key), - ConsumedInputLease::SuppressRepeats, - ); - true - } -} - -#[cfg(test)] -mod tests { - use bytes::Bytes; - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - use crate::{app::Mode, events::AppEvent, workspace::Workspace}; - - fn app_with_screen_bytes_and_input( - bytes: &[u8], - ) -> ( - App, - crate::layout::PaneInfo, - tokio::sync::mpsc::Receiver, - ) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - bytes, - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, info, input_rx) - } - - fn drag_select_range( - app: &mut App, - info: &crate::layout::PaneInfo, - start_col: u16, - end_col: u16, - ) { - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + start_col; - let end_col = info.inner_rect.x + end_col; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - } - - fn clipboard_write_content(app: &mut App) -> Vec { - match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, - event => panic!("unexpected event: {event:?}"), - } - } - - fn assert_visible_selection(app: &App) { - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible)); - } - - #[tokio::test] - async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 0x43, - virtual_scan_code: 0x2e, - unicode: 'c' as u16, - control_key_state: 0x0008, - }); - let source_id = 41; - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - - let content = clipboard_write_content(&mut app); - assert_eq!(content, b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - - app.handle_internal_event(AppEvent::ClipboardWrite { content }); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Key(ctrl_c.clone()), - crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Repeat), - ), - ], - false, - ); - assert_eq!(app.input_leases.len(), 1); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - let expected = if cfg!(windows) { - b"\x1b[67;46;99;1;8;1_".as_slice() - } else { - b"\x03".as_slice() - }; - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - expected - ); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_cmd_c_copies_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER)); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_shortcut_before_delayed_mouse_up_copies_in_progress_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - let source_id = 41; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )), - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - end_col, - row, - )), - ], - false, - ); - assert_visible_selection(&app); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_in_progress)); - assert!(app.state.selection_autoscroll.is_some()); - assert!(app.selection_autoscroll_deadline.is_some()); - - let cmd_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(cmd_c.clone())], - false, - ); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!(app.input_leases.len(), 1); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - cmd_c.with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - end_col, - row, - ))], - false, - ); - - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.input_leases.is_empty()); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() { - let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut selection = crate::selection::Selection::range( - pane_id, - 0, - 0, - 4, - app.state - .pane_scroll_metrics(&app.terminal_runtimes, pane_id), - ); - assert!(selection.finish()); - app.state.selection = Some(selection); - app.state.copy_on_select = true; - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b""); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_requires_exact_modifiers() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx - .try_recv() - .expect("forwarded Ctrl-Shift-C") - .as_ref(), - b"\x03" - ); - } -} diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs deleted file mode 100644 index 8bdbfd38..00000000 --- a/src/app/input/copy_mode.rs +++ /dev/null @@ -1,2153 +0,0 @@ -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; - -use crate::{ - app::{ - state::{CopyModeSearchDirection, CopyModeSearchPrompt, CopyModeSelection, CopyModeState}, - App, AppState, Mode, - }, - input::TerminalKey, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -impl App { - pub(crate) fn handle_copy_mode_key(&mut self, key: TerminalKey) { - if key.kind == KeyEventKind::Release { - return; - } - self.state.update_dismissed = true; - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return; - } - self.state - .handle_copy_mode_key(&self.terminal_runtimes, key); - self.dispatch_pending_clipboard_write(); - } -} - -impl AppState { - pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(pane_id) = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()) - else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - if info.inner_rect.width == 0 || info.inner_rect.height == 0 { - return; - } - - let cursor = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - .and_then(|rt| rt.cursor_state(info.inner_rect, true)) - .filter(|cursor| cursor.visible) - .map(|cursor| { - ( - cursor.y.saturating_sub(info.inner_rect.y), - cursor.x.saturating_sub(info.inner_rect.x), - ) - }) - .unwrap_or_else(|| (info.inner_rect.height.saturating_sub(1), 0)); - let entry_offset_from_bottom = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map_or(0, |metrics| metrics.offset_from_bottom); - - self.clear_selection(); - self.copy_mode = Some(CopyModeState { - pane_id, - cursor_row: cursor.0.min(info.inner_rect.height.saturating_sub(1)), - cursor_col: cursor.1.min(info.inner_rect.width.saturating_sub(1)), - entry_offset_from_bottom, - selection: None, - search: crate::app::state::CopyModeSearchState { - geometry: Some((info.inner_rect.width, info.inner_rect.height)), - ..Default::default() - }, - }); - self.mode = Mode::Copy; - } - - pub(crate) fn handle_copy_mode_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) { - if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) { - return; - } - match key.code { - KeyCode::Esc => { - let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { - copy_mode.selection.is_some() - || !copy_mode.search.query.is_empty() - || !copy_mode.search.matches.is_empty() - || copy_mode.search.direction.is_some() - }); - if should_clear { - self.clear_copy_mode_selection(); - if let Some(search) = self - .copy_mode - .as_mut() - .map(|copy_mode| &mut copy_mode.search) - { - let geometry = search.geometry; - *search = crate::app::state::CopyModeSearchState { - geometry, - ..Default::default() - }; - } - return; - } - self.exit_copy_mode(terminal_runtimes, false); - return; - } - KeyCode::Enter => { - self.exit_copy_mode(terminal_runtimes, true); - return; - } - KeyCode::Left => { - self.move_copy_cursor(terminal_runtimes, 0, -1); - return; - } - KeyCode::Down => { - self.move_copy_cursor(terminal_runtimes, 1, 0); - return; - } - KeyCode::Up => { - self.move_copy_cursor(terminal_runtimes, -1, 0); - return; - } - KeyCode::Right => { - self.move_copy_cursor(terminal_runtimes, 0, 1); - return; - } - KeyCode::PageUp => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false); - return; - } - KeyCode::PageDown => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false); - return; - } - KeyCode::Home => { - self.copy_mode_line_edge(terminal_runtimes, false); - return; - } - KeyCode::End => { - self.copy_mode_line_edge(terminal_runtimes, true); - return; - } - _ => {} - } - - match (key.code, key.modifiers) { - (KeyCode::Char('b'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false) - } - (KeyCode::Char('f'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false) - } - (KeyCode::Char('u'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, true) - } - (KeyCode::Char('d'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, true) - } - _ => {} - } - - let Some(ch) = copy_mode_command_char(key) else { - return; - }; - match ch { - 'q' => self.exit_copy_mode(terminal_runtimes, false), - 'y' => self.exit_copy_mode(terminal_runtimes, true), - 'v' | ' ' => self.begin_copy_mode_selection(terminal_runtimes), - 'V' => self.select_copy_mode_line(terminal_runtimes), - 'h' => self.move_copy_cursor(terminal_runtimes, 0, -1), - 'j' => self.move_copy_cursor(terminal_runtimes, 1, 0), - 'k' => self.move_copy_cursor(terminal_runtimes, -1, 0), - 'l' => self.move_copy_cursor(terminal_runtimes, 0, 1), - 'g' => self.copy_mode_history_top(terminal_runtimes), - 'G' => self.copy_mode_history_bottom(terminal_runtimes), - '0' => self.copy_mode_line_edge(terminal_runtimes, false), - '$' => self.copy_mode_line_edge(terminal_runtimes, true), - '^' => self.copy_mode_first_non_blank(terminal_runtimes), - '/' => self.open_copy_mode_search(CopyModeSearchDirection::Forward), - '?' => self.open_copy_mode_search(CopyModeSearchDirection::Backward), - 'n' => self.repeat_copy_mode_search(terminal_runtimes, false), - 'N' => self.repeat_copy_mode_search(terminal_runtimes, true), - 'w' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextStart), - 'b' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousStart), - 'e' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextEnd), - 'W' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigStart), - 'B' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousBigStart), - 'E' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigEnd), - '{' => self.copy_mode_paragraph(terminal_runtimes, -1), - '}' => self.copy_mode_paragraph(terminal_runtimes, 1), - _ => {} - } - } - - fn handle_copy_mode_search_prompt_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) -> bool { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return false; - }; - let Some(prompt) = copy_mode.search.prompt.as_mut() else { - return false; - }; - match key.code { - KeyCode::Esc => { - copy_mode.search.prompt = None; - } - KeyCode::Enter => { - let direction = prompt.direction; - let query = std::mem::take(&mut prompt.query); - copy_mode.search.prompt = None; - self.submit_copy_mode_search(terminal_runtimes, query, direction, false); - } - KeyCode::Backspace => { - prompt.query.pop(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - prompt.query.clear(); - } - _ => { - if let Some(ch) = copy_mode_command_char(key) { - prompt.query.push(ch); - } - } - } - true - } - - fn open_copy_mode_search(&mut self, direction: CopyModeSearchDirection) { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - copy_mode.search.prompt = Some(CopyModeSearchPrompt { - direction, - query: String::new(), - }); - } - - fn repeat_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - reverse: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if copy_mode.search.query.is_empty() { - return; - } - let Some(mut direction) = copy_mode.search.direction else { - return; - }; - if reverse { - direction = direction.reversed(); - } - self.submit_copy_mode_search( - terminal_runtimes, - copy_mode.search.query.clone(), - direction, - true, - ); - } - - fn submit_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - query: String, - direction: CopyModeSearchDirection, - repeat: bool, - ) { - if query.is_empty() { - return; - } - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return; - }; - let Some(metrics) = runtime.scroll_metrics() else { - return; - }; - let cursor = crate::pane::TerminalTextPoint { - row: viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)), - col: copy_mode.cursor_col, - }; - let previous_match = repeat - .then(|| { - copy_mode - .search - .current - .and_then(|index| copy_mode.search.matches.get(index).copied()) - }) - .flatten() - .filter(|text_match| { - text_match.start == cursor && runtime.text_match_is_current(*text_match) - }); - let matches = runtime.search_text_matches(&query, query.chars().any(char::is_uppercase)); - let current = search_match_index(&matches, direction, cursor, previous_match); - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.search.query = query; - if !repeat { - copy_mode.search.direction = Some(direction); - } - copy_mode.search.matches = matches; - copy_mode.search.current = current; - } - - let Some(target) = current.and_then(|index| { - self.copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.matches.get(index).copied()) - }) else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target.start, true); - } - - fn move_copy_cursor_to_absolute( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - target: crate::pane::TerminalTextPoint, - reserve_overlay_row: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let current_top = viewport_top_row(metrics); - let max_cursor_row = info - .inner_rect - .height - .saturating_sub(if reserve_overlay_row { 2 } else { 1 }); - let desired_top = if target.row < current_top { - target.row - } else if target.row > current_top.saturating_add(u32::from(max_cursor_row)) { - target.row.saturating_sub(u32::from(max_cursor_row)) - } else { - current_top - }; - let desired_offset = metrics - .max_offset_from_bottom - .saturating_sub(desired_top as usize); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, desired_offset); - let Some(updated_metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let updated_top = viewport_top_row(updated_metrics); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = target - .row - .saturating_sub(updated_top) - .min(u32::from(info.inner_rect.height.saturating_sub(1))) - as u16; - copy_mode.cursor_col = target.col.min(info.inner_rect.width.saturating_sub(1)); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - pub(crate) fn cancel_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - self.exit_copy_mode(terminal_runtimes, false); - } - - fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { - let restore_scroll = self - .copy_mode - .as_ref() - .map(|copy_mode| (copy_mode.pane_id, copy_mode.entry_offset_from_bottom)); - if copy { - self.copy_selection(terminal_runtimes); - } else { - self.clear_selection(); - } - if let Some((pane_id, offset_from_bottom)) = restore_scroll { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, offset_from_bottom); - } - self.copy_mode = None; - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn begin_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - if copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - self.selection = Some(Selection::anchor( - copy_mode.pane_id, - copy_mode.cursor_row, - copy_mode.cursor_col, - metrics, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Character); - } - } - - fn select_copy_mode_line(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - return; - }; - let end_col = info.inner_rect.width.saturating_sub(1); - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let anchor_row = Selection::absolute_row_for_viewport(cursor_row, metrics); - self.selection = Some(Selection::line_range( - pane_id, anchor_row, anchor_row, end_col, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row }); - } - } - - fn move_copy_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - row_delta: i16, - col_delta: i16, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let mut cursor_col = copy_mode.cursor_col; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - - if col_delta < 0 { - cursor_col = cursor_col.saturating_sub(col_delta.unsigned_abs()); - } else if col_delta > 0 { - cursor_col = cursor_col - .saturating_add(col_delta as u16) - .min(info.inner_rect.width.saturating_sub(1)); - } - - if row_delta < 0 { - let delta = row_delta.unsigned_abs(); - if cursor_row >= delta { - cursor_row -= delta; - } else { - self.scroll_pane_up(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = 0; - } - } else if row_delta > 0 { - let delta = row_delta as u16; - let bottom = info.inner_rect.height.saturating_sub(1); - if cursor_row.saturating_add(delta) <= bottom { - cursor_row += delta; - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = bottom; - } - } - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn scroll_copy_mode_page( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - direction: i16, - half_page: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let lines = copy_mode_page_lines(info.inner_rect.height, half_page); - if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) { - if direction < 0 { - let next_offset = metrics.offset_from_bottom.saturating_add(lines); - if next_offset > metrics.max_offset_from_bottom { - let scrolled_lines = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom); - let cursor_lines = lines.saturating_sub(scrolled_lines); - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom, - ); - cursor_row = - cursor_row.saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); - } else { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, next_offset); - } - } else if metrics.offset_from_bottom < lines { - let cursor_lines = lines.saturating_sub(metrics.offset_from_bottom); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - cursor_row = cursor_row - .saturating_add(cursor_lines.min(u16::MAX as usize) as u16) - .min(info.inner_rect.height.saturating_sub(1)); - } else { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.offset_from_bottom - lines, - ); - } - } else if direction < 0 { - self.scroll_pane_up(terminal_runtimes, pane_id, lines); - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, lines); - } - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_top(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - self.set_pane_scroll_offset(terminal_runtimes, pane_id, metrics.max_offset_from_bottom); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_bottom(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_row = info.inner_rect.height.saturating_sub(1); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_line_edge(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, end: bool) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_col = if end { - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - last_character_col(&text) - .unwrap_or(0) - .min(info.inner_rect.width.saturating_sub(1)) - } else { - 0 - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_first_non_blank(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let cursor_row = copy_mode.cursor_row; - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = first_non_blank_col(&text).unwrap_or(0); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_word_motion( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - motion: WordMotion, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) else { - return; - }; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id) - else { - return; - }; - let absolute_row = - viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)); - let motion = match motion { - WordMotion::NextStart => crate::pane::TerminalWordMotion::NextStart, - WordMotion::PreviousStart => crate::pane::TerminalWordMotion::PreviousStart, - WordMotion::NextEnd => crate::pane::TerminalWordMotion::NextEnd, - WordMotion::NextBigStart => crate::pane::TerminalWordMotion::NextBigStart, - WordMotion::PreviousBigStart => crate::pane::TerminalWordMotion::PreviousBigStart, - WordMotion::NextBigEnd => crate::pane::TerminalWordMotion::NextBigEnd, - }; - let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion) - else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target, false); - } - - fn copy_mode_paragraph(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, direction: i16) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(pane_height) = self - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height) - else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let limit = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.max_offset_from_bottom + metrics.viewport_rows) - .unwrap_or(pane_height as usize) - .clamp(1, 1000); - - for _ in 0..limit { - let before = self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }); - let before_offset = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.offset_from_bottom); - - self.move_copy_cursor(terminal_runtimes, direction, 0); - - let Some(after) = self.copy_mode.as_ref() else { - return; - }; - if self - .copy_mode_visible_row_text(terminal_runtimes, after.cursor_row) - .is_some_and(|text| text.trim().is_empty()) - { - return; - } - - let Some(after_metrics) = self.pane_scroll_metrics(terminal_runtimes, after.pane_id) - else { - continue; - }; - let did_not_move = before - == self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }) - && before_offset == Some(after_metrics.offset_from_bottom); - let at_top = direction < 0 - && after.cursor_row == 0 - && after_metrics.offset_from_bottom == after_metrics.max_offset_from_bottom; - let at_bottom = direction > 0 - && after.cursor_row + 1 >= pane_height - && after_metrics.offset_from_bottom == 0; - if did_not_move || at_top || at_bottom { - return; - } - } - } - - fn copy_mode_visible_row_text( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - viewport_row: u16, - ) -> Option { - let copy_mode = self.copy_mode.as_ref()?; - let ws_idx = self.active?; - let info = self.pane_info_by_id(copy_mode.pane_id)?; - if viewport_row >= info.inner_rect.height || info.inner_rect.width == 0 { - return None; - } - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let row_selection = Selection::range( - copy_mode.pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id)? - .extract_selection(&row_selection) - } - - pub(crate) fn copy_mode_pane_is_focused(&self) -> bool { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return false; - }; - self.current_pane_focus_target() - .is_some_and(|target| target.pane_id == copy_mode.pane_id) - } - - pub(crate) fn sync_copy_mode_with_focus(&mut self) { - if self.copy_mode.is_none() { - return; - } - if !matches!( - self.mode, - Mode::Copy | Mode::Terminal | Mode::Navigate | Mode::Prefix - ) { - return; - } - if self.copy_mode_pane_is_focused() { - self.mode = Mode::Copy; - } else if self.active.is_some() { - self.clear_copy_mode_selection(); - self.mode = Mode::Terminal; - } else { - self.clear_copy_mode_selection(); - self.mode = Mode::Navigate; - } - } - - pub(crate) fn settle_terminal_mode_after_focus(&mut self) { - self.mode = Mode::Terminal; - self.sync_copy_mode_with_focus(); - } - - pub(crate) fn sync_copy_mode_search_geometry(&mut self) { - let geometry = self.copy_mode.as_ref().and_then(|copy_mode| { - self.view - .pane_infos - .iter() - .find(|info| info.id == copy_mode.pane_id) - .map(|info| (info.inner_rect.width, info.inner_rect.height)) - }); - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - if let Some(geometry) = geometry { - if copy_mode.search.geometry.is_some() && copy_mode.search.geometry != Some(geometry) { - copy_mode.search.matches.clear(); - copy_mode.search.current = None; - } - copy_mode.search.geometry = Some(geometry); - } - } - - pub(crate) fn clear_copy_mode_selection(&mut self) { - self.clear_selection(); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = None; - } - } - - pub(crate) fn clear_copy_mode_for_removed_panes( - &mut self, - pane_ids: impl IntoIterator, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if pane_ids - .into_iter() - .any(|pane_id| pane_id == copy_mode.pane_id) - { - self.clear_selection(); - self.copy_mode = None; - if self.mode == Mode::Copy { - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - } - - fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(selection) = copy_mode.selection else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - match selection { - CopyModeSelection::Character => { - let screen_col = info.inner_rect.x.saturating_add(copy_mode.cursor_col); - let screen_row = info.inner_rect.y.saturating_add(copy_mode.cursor_row); - self.update_selection_cursor( - terminal_runtimes, - copy_mode.pane_id, - screen_col, - screen_row, - ); - } - CopyModeSelection::Linewise { anchor_row } => { - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let cursor_row = - Selection::absolute_row_for_viewport(copy_mode.cursor_row, metrics); - self.selection = Some(Selection::line_range( - copy_mode.pane_id, - anchor_row, - cursor_row, - info.inner_rect.width.saturating_sub(1), - )); - } - } - } -} - -impl CopyModeSearchDirection { - fn reversed(self) -> Self { - match self { - Self::Forward => Self::Backward, - Self::Backward => Self::Forward, - } - } -} - -fn viewport_top_row(metrics: crate::pane::ScrollMetrics) -> u32 { - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32 -} - -fn search_match_index( - matches: &[crate::pane::TerminalTextMatch], - direction: CopyModeSearchDirection, - cursor: crate::pane::TerminalTextPoint, - previous: Option, -) -> Option { - if matches.is_empty() { - return None; - } - match direction { - CopyModeSearchDirection::Forward => { - let origin = previous.map_or(cursor, |text_match| text_match.end); - matches - .iter() - .position(|text_match| text_match.start > origin) - .or(Some(0)) - } - CopyModeSearchDirection::Backward => { - let origin = previous.map_or(cursor, |text_match| text_match.start); - matches - .iter() - .rposition(|text_match| text_match.end < origin) - .or_else(|| matches.len().checked_sub(1)) - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WordMotion { - NextStart, - PreviousStart, - NextEnd, - NextBigStart, - PreviousBigStart, - NextBigEnd, -} - -fn first_non_blank_col(text: &str) -> Option { - let mut col = 0u16; - for ch in text.chars() { - if !ch.is_whitespace() { - return Some(col); - } - col = col.saturating_add(char_cell_width(ch)); - } - None -} - -fn last_character_col(text: &str) -> Option { - let mut col = 0u16; - let mut last_col = None; - for ch in text.chars() { - let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); - if width > 0 { - last_col = Some(col); - col = col.saturating_add(width); - } - } - last_col -} - -fn char_cell_width(ch: char) -> u16 { - u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) -} - -fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { - if height <= 2 { - 1 - } else if half_page { - usize::from(height / 2) - } else { - usize::from(height - 2) - } -} - -fn copy_mode_command_char(key: TerminalKey) -> Option { - if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - return None; - } - - if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(ch); - } - - let KeyCode::Char(ch) = key.code else { - return None; - }; - if key.modifiers.contains(KeyModifiers::SHIFT) { - Some(shifted_ascii_char(ch).unwrap_or(ch)) - } else { - Some(ch) - } -} - -fn shifted_ascii_char(ch: char) -> Option { - match ch { - 'a'..='z' => Some(ch.to_ascii_uppercase()), - '1' => Some('!'), - '2' => Some('@'), - '3' => Some('#'), - '4' => Some('$'), - '5' => Some('%'), - '6' => Some('^'), - '7' => Some('&'), - '8' => Some('*'), - '9' => Some('('), - '0' => Some(')'), - '-' => Some('_'), - '=' => Some('+'), - '[' => Some('{'), - ']' => Some('}'), - '\\' => Some('|'), - ';' => Some(':'), - '\'' => Some('"'), - ',' => Some('<'), - '.' => Some('>'), - '/' => Some('?'), - '`' => Some('~'), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::super::{app_for_mouse_test, numbered_lines_bytes}; - use super::*; - use crate::{events::AppEvent, workspace::Workspace}; - use ratatui::layout::Rect; - - fn app_with_copy_runtime( - runtime: impl FnOnce(u16, u16) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 20, 5)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - runtime(info.inner_rect.width, info.inner_rect.height), - ); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, pane_id) - } - - fn app_with_copy_screen(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_copy_scrollback(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn app_with_split_copy_runtime( - bytes: &[u8], - first_runtime: impl FnOnce(u16, u16, &[u8]) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 40, 5)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - first_runtime( - first_info.inner_rect.width, - first_info.inner_rect.height, - bytes, - ), - ); - ws.tabs[0].runtimes.insert( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width, - second_info.inner_rect.height, - b"", - ), - ); - ws.tabs[0].layout.focus_pane(first_pane); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, first_pane, second_pane) - } - - fn app_with_split_copy_screen( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_split_copy_scrollback( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn copy_mode_clipboard_text(app: &mut App) -> String { - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => { - String::from_utf8(content).expect("utf8 clipboard") - } - other => panic!("unexpected event: {other:?}"), - } - } - - fn copy_mode_viewport_top_row(app: &App, pane_id: crate::layout::PaneId) -> usize { - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics"); - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - } - - fn copy_mode_offset_from_bottom(app: &App, pane_id: crate::layout::PaneId) -> usize { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics") - .offset_from_bottom - } - - fn copy_mode_scroll_metrics( - app: &App, - pane_id: crate::layout::PaneId, - ) -> crate::pane::ScrollMetrics { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics") - } - - fn refresh_split_pane_infos(app: &mut App) { - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .expect("active tab") - .layout - .panes(Rect::new(0, 0, 40, 5)); - } - - #[tokio::test] - async fn enter_copy_mode_tracks_focused_pane() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - pane_id - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_b_uses_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.prefix_code = KeyCode::Char('a'); - app.state.prefix_mods = KeyModifiers::CONTROL; - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_prefix_takes_priority_over_ctrl_b_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_some()); - } - - #[tokio::test] - async fn copy_mode_prefix_escape_returns_to_copy_mode() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - } - - #[tokio::test] - async fn copy_mode_prefix_focus_keeps_copy_mode_on_source_pane() { - let (mut app, first_pane, second_pane) = app_with_split_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - assert_eq!(copy_mode.pane_id, first_pane); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.copy_mode, Some(copy_mode.clone())); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - - refresh_split_pane_infos(&mut app); - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); - } - - #[tokio::test] - async fn copy_mode_focus_away_preserves_scrollback_position() { - let bytes = numbered_lines_bytes(64); - let (mut app, first_pane, second_pane) = app_with_split_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let scrolled_offset = copy_mode_offset_from_bottom(&app, first_pane); - assert!(scrolled_offset > 0); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - assert_eq!( - copy_mode_offset_from_bottom(&app, first_pane), - scrolled_offset - ); - } - - #[tokio::test] - async fn copy_mode_cancel_restores_scroll_after_workspace_switch() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state - .workspaces - .push(crate::workspace::Workspace::test_new("other")); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.state.switch_workspace(1); - app.state.cancel_copy_mode(&app.terminal_runtimes); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_clears_when_source_tab_closes_after_focus_away() { - let (mut app, first_pane, _) = app_with_split_copy_screen(b"alpha\nbeta\n"); - let survivor_tab = app.state.workspaces[0].test_add_tab(Some("survivor")); - let survivor_pane = app.state.workspaces[0].tabs[survivor_tab].root_pane; - let survivor_terminal = app.state.workspaces[0].tabs[survivor_tab].panes[&survivor_pane] - .attached_terminal_id - .clone(); - app.state.terminals.insert( - survivor_terminal.clone(), - crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()), - ); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - first_pane - ); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_some()); - - assert!(!app.state.close_tab()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - app.state.assert_invariants_for_test(); - } - - #[tokio::test] - async fn copy_mode_ctrl_f_uses_page_down() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let page_lines = copy_mode_page_lines(height, false); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, page_lines); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_line_end_stops_at_last_character() { - let (mut app, _) = app_with_copy_screen(b"hello\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::End, KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - let (mut empty_app, _) = app_with_copy_screen(b"\r\n"); - empty_app - .state - .enter_copy_mode(&empty_app.terminal_runtimes); - if let Some(copy_mode) = empty_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 7; - } - empty_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - empty_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 0 - ); - - let (mut wide_app, _) = app_with_copy_screen("a界\r\n".as_bytes()); - wide_app.state.enter_copy_mode(&wide_app.terminal_runtimes); - if let Some(copy_mode) = wide_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - wide_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - wide_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 1 - ); - } - - #[tokio::test] - async fn copy_mode_word_motions_use_visible_row_words() { - let (mut app, _) = app_with_copy_screen(b"foo bar baz\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 6 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - fn submit_copy_search(app: &mut App, marker: char, query: &str) { - app.handle_copy_mode_key(TerminalKey::new( - KeyCode::Char(marker), - KeyModifiers::empty(), - )); - for ch in query.chars() { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty())); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())); - } - - #[tokio::test] - async fn copy_mode_search_wraps_and_repeats_in_both_directions() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '/', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.matches.len(), 2); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 6); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 5); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - assert_eq!( - app.state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .current, - Some(1) - ); - } - - #[tokio::test] - async fn copy_mode_backward_search_uses_last_match_before_cursor() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '?', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - } - - #[tokio::test] - async fn copy_mode_shifted_slash_opens_backward_search() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.direction), - Some(CopyModeSearchDirection::Backward) - ); - } - - #[tokio::test] - async fn copy_mode_search_extends_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - - submit_copy_search(&mut app, '/', "needle"); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta n"); - } - - #[tokio::test] - async fn copy_mode_search_prompt_escape_and_failure_preserve_copy_mode() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let before = ( - copy_mode_viewport_top_row(&app, pane_id), - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Copy); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .prompt - .is_none()); - - submit_copy_search(&mut app, '/', "missing"); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.query, "missing"); - assert!(copy_mode.search.matches.is_empty()); - assert_eq!( - ( - copy_mode_viewport_top_row(&app, pane_id), - copy_mode.cursor_row, - copy_mode.cursor_col, - ), - before - ); - } - - #[tokio::test] - async fn copy_mode_escape_clears_search_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert!(search.query.is_empty()); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - assert_eq!(app.state.mode, Mode::Copy); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_escape_clears_selection_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - assert!(app.state.selection.is_some()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Copy); - assert!(app.state.selection.is_none()); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .selection - .is_none()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_search_prompt_accepts_paste_without_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - - assert!(app.paste_into_active_text_input("needle")); - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.query.as_str()), - Some("needle") - ); - assert_eq!(app.state.mode, Mode::Copy); - } - - #[tokio::test] - async fn copy_mode_word_motions_cross_rows_and_respect_separators() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar_baz\r\nqux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [3, 4] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 4); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_skip_punctuation_runs() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [8, 12] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 14 - ); - for expected_col in [12, 8, 0] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - - // Lowercase motions keep their punctuation-aware behavior. - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 3 - ); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_accept_shifted_codepoints_and_cross_rows() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar baz\r\nqux/quux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_extend_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "foo.bar b"); - } - - #[tokio::test] - async fn copy_mode_search_does_not_change_live_follow_behavior() { - let bytes = numbered_lines_bytes(32); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "missing"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime") - .test_process_pty_bytes(b"live output\r\n"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - submit_copy_search(&mut app, '?', "000000"); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - let visible_before = runtime.visible_text(); - runtime.test_process_pty_bytes(b"more output\r\n"); - assert_eq!(runtime.visible_text(), visible_before); - } - - #[tokio::test] - async fn copy_mode_resize_clears_matches_but_keeps_query() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - assert!(!app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .matches - .is_empty()); - - app.state.view.pane_infos[0].inner_rect.width = app.state.view.pane_infos[0] - .inner_rect - .width - .saturating_sub(1); - app.state.sync_copy_mode_search_geometry(); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert_eq!(search.query, "needle"); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - } - - #[tokio::test] - async fn copy_mode_hidden_source_does_not_look_like_a_resize() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - let before = app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .clone(); - - app.state.view.pane_infos.clear(); - app.state.sync_copy_mode_search_geometry(); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").search, - before - ); - } - - #[tokio::test] - async fn copy_mode_shift_v_y_copies_visible_line() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_down() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_up() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_reverses_without_character_tail() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_horizontal_motion_keeps_linewise_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_page_up_keeps_linewise_scrollback_selection() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - let anchor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let cursor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert!(cursor_row < anchor_row); - let expected = (cursor_row..=anchor_row) - .map(|row| format!("{row:06}")) - .collect::>() - .join("\n"); - assert_eq!(copy_mode_clipboard_text(&mut app), expected); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_page_up_uses_tmux_page_size() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_ctrl_u_moves_cursor_when_history_top_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - let metrics = copy_mode_scroll_metrics(&app, pane_id); - assert!(metrics.max_offset_from_bottom >= lines); - app.state.set_pane_scroll_offset( - &app.terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom - lines + 1, - ); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = bottom; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - let expected_cursor_delta = 1; - assert_eq!( - copy_mode_offset_from_bottom(&app, pane_id), - metrics.max_offset_from_bottom - ); - assert_eq!( - copy_mode.cursor_row, - bottom.saturating_sub(expected_cursor_delta as u16) - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_d_moves_cursor_when_live_bottom_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - assert!(lines > 1); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, lines - 1); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert_eq!(copy_mode.cursor_row, 1); - } - - #[tokio::test] - async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_q_restores_entry_scrollback_offset() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - let entry_offset = 3; - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, entry_offset); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > entry_offset); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - } - - #[tokio::test] - async fn shifted_punctuation_keys_work_with_enhanced_key_reporting() { - let (mut app, _) = app_with_copy_screen(b"foo\r\n\r\nbar\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 2; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 1 - ); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT) - .with_shifted_codepoint('}' as u32), - ); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - } - - #[tokio::test] - async fn copy_mode_v_y_copies_selection_and_exits() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => assert_eq!(content, b"alp"), - other => panic!("unexpected event: {other:?}"), - } - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_same_tab_switch_preserves_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - - assert!(app.state.switch_workspace_tab(0, 0)); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - assert_eq!(copy_mode_clipboard_text(&mut app), "alp"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } -} diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs deleted file mode 100644 index 10edc0c0..00000000 --- a/src/app/input/mod.rs +++ /dev/null @@ -1,1051 +0,0 @@ -//! Input handling — translates crossterm key/mouse events into state mutations. - -use bytes::Bytes; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use tracing::warn; - -use crate::app::PaneClickState; -use crate::input::TerminalKey; -#[cfg(test)] -use ratatui::layout::Direction; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScrollbarClickTarget { - Thumb { grab_row_offset: u16 }, - Track { offset_from_bottom: usize }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(test)] -enum WheelRouting { - HostScroll, - MouseReport, - AlternateScroll, -} - -const WORKSPACE_DRAG_THRESHOLD: u16 = 1; -const TAB_DRAG_THRESHOLD: u16 = 1; - -fn modified_url_click_modifier() -> KeyModifiers { - KeyModifiers::CONTROL -} - -#[cfg(test)] -#[test] -fn modified_url_click_modifier_matches_terminal_mouse_reporting() { - assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL); -} - -mod clipboard; -mod copy_mode; -mod lease; -mod modal; -mod mouse; -mod navigate; -mod overlays; -mod selection; -mod settings; -mod sidebar; -mod terminal; - -pub(crate) use self::{ - lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan}, - modal::{ - handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, - insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, - open_new_workspace_dialog, - }, - navigate::{ - terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, - }, - settings::open_settings_at, -}; -use self::{ - modal::{ - modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, - }, - mouse::MouseAction, - settings::SettingsAction, -}; -use super::state::{AppState, Mode}; -use super::App; - -// --------------------------------------------------------------------------- -// Key handling -// --------------------------------------------------------------------------- - -impl App { - pub(super) async fn handle_key( - &mut self, - key: TerminalKey, - ) -> Option { - if self.state.popup_pane.is_some() { - return self.handle_terminal_key(key).await; - } - let key_event = key.as_key_event(); - if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) { - if let Some(text) = crate::platform::read_clipboard_text() { - self.paste_into_active_text_input(&text); - } - return None; - } - - match self.state.mode { - Mode::Terminal => return self.handle_terminal_key(key).await, - Mode::Prefix => self.handle_prefix_key(key), - Mode::Navigate => self.handle_navigate_key(key), - Mode::Copy => self.handle_copy_mode_key(key), - _ => match self.state.mode { - Mode::Onboarding => self.handle_onboarding_key(key_event), - Mode::ReleaseNotes => self.handle_release_notes_key(key_event), - Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event), - Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(), - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - self.handle_rename_key_via_api(key_event) - } - Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event), - Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), - Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), - Mode::Resize => self.handle_resize_key_via_api(key), - Mode::ConfirmClose => self.handle_confirm_close_key_via_api(key_event), - Mode::ContextMenu => { - self.handle_context_menu_key_via_api(key_event); - } - Mode::Settings => self.handle_settings_key(key_event), - Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), - Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), - Mode::Navigator => { - handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event) - } - Mode::Terminal => unreachable!(), - }, - } - None - } - - pub(crate) fn handle_text_commit_headless(&mut self, text: &str) { - if text.is_empty() { - return; - } - if self.state.popup_pane.is_some() { - if let Some(runtime) = self.popup_runtime() { - let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); - } else { - self.close_popup_pane(); - } - return; - } - if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(text); - return; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - if let Some(ws_idx) = self.state.active { - if let Some(runtime) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - { - let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); - } - } - } - - pub(super) async fn handle_text_commit(&mut self, text: String) { - if text.is_empty() { - return; - } - if self.state.popup_pane.is_some() { - if let Some(runtime) = self.popup_runtime() { - let _ = runtime.send_bytes(Bytes::from(text)).await; - } else { - self.close_popup_pane(); - } - return; - } - if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - return; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - if let Some(ws_idx) = self.state.active { - if let Some(runtime) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - { - let _ = runtime.send_bytes(Bytes::from(text)).await; - } - } - } - - pub(super) async fn handle_paste(&mut self, text: String) { - if self.state.popup_pane.is_some() { - if let Some(runtime) = self.popup_runtime() { - let _ = runtime.send_paste(text).await; - } else { - self.close_popup_pane(); - } - return; - } - if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - return; - } - - if let Some(ws_idx) = self.state.active { - if let Some(rt) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - { - let _ = rt.send_paste(text).await; - } - } - } - - pub(crate) fn paste_into_active_text_input(&mut self, text: &str) -> bool { - match self.state.mode { - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - insert_rename_input_text(&mut self.state, text); - true - } - Mode::NewLinkedWorktree => { - self.insert_worktree_create_text(text); - true - } - Mode::OpenExistingWorktree => { - if !self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) - { - return false; - } - self.insert_worktree_open_search_text(text); - true - } - Mode::Navigator => { - if !self.state.navigator.search_focused { - return false; - } - insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text); - true - } - Mode::KeybindHelp => { - if !self.state.keybind_help.search_focused { - return false; - } - insert_keybind_help_query_text(&mut self.state, text); - true - } - Mode::Copy => { - let Some(prompt) = self - .state - .copy_mode - .as_mut() - .and_then(|copy_mode| copy_mode.search.prompt.as_mut()) - else { - return false; - }; - prompt - .query - .extend(text.chars().filter(|ch| !ch.is_control())); - true - } - _ => false, - } - } - - pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(), - _ => { - if let Some(ModalAction::Continue) = - modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) - { - self.open_settings_from_onboarding(); - } - } - } - } - - pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1), - KeyCode::PageUp => self.scroll_release_notes(-8), - KeyCode::PageDown => self.scroll_release_notes(8), - KeyCode::Home => { - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = 0; - } - } - KeyCode::End => { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = max_scroll; - } - } - _ => { - if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) - { - self.dismiss_release_notes(); - } - } - } - } - - pub(crate) fn handle_product_announcement_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') => self.scroll_product_announcement(-1), - KeyCode::Down | KeyCode::Char('j') => self.scroll_product_announcement(1), - KeyCode::PageUp => self.scroll_product_announcement(-8), - KeyCode::PageDown => self.scroll_product_announcement(8), - KeyCode::Home => { - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = 0; - } - } - KeyCode::End => { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = max_scroll; - } - } - _ => { - if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) - { - self.dismiss_product_announcement(); - } - } - } - } - - pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { - self.handle_mouse_from_input_source(super::LOCAL_INPUT_SOURCE, mouse); - } - - pub(super) fn handle_mouse_from_input_source( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - ) { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - self.pending_url_click_sources.remove(&source_id); - } - MouseEventKind::Drag(MouseButton::Left) - if self.pending_url_click_sources.contains(&source_id) => - { - return; - } - MouseEventKind::Up(MouseButton::Left) - if self.pending_url_click_sources.remove(&source_id) => - { - return; - } - _ => {} - } - - if self.state.popup_pane.is_some() { - self.handle_popup_mouse(mouse); - return; - } - if self.handle_overlay_mouse(mouse) { - return; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self.state.on_sidebar_divider(mouse.column, mouse.row) - { - let now = std::time::Instant::now(); - let is_double_click = self - .last_sidebar_divider_click - .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW); - self.last_sidebar_divider_click = Some(now); - - if is_double_click { - self.state.sidebar_width = self.state.default_sidebar_width; - self.state.sidebar_width_source = - crate::app::state::SidebarWidthSource::ConfigDefault; - self.state.sidebar_width_auto = false; - self.state.mark_session_dirty(); - self.state.drag = None; - return; - } - } - - if self.handle_modified_url_click(source_id, mouse) { - return; - } - - let handled_pane_double_click = self.handle_pane_double_click(mouse); - if !handled_pane_double_click { - self.focus_pane_before_mouse_press(mouse); - } - - let previous_agent_panel_sort = self.state.agent_panel_sort; - let previous_settings_section = self.state.settings.section; - if !handled_pane_double_click { - if let Some(action) = - self.state - .handle_mouse(&mut self.terminal_runtimes, source_id, mouse) - { - match action { - MouseAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.mouse.workspace.create") - } - MouseAction::Settings(action) => match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveStatusIndicators(style) => { - self.save_status_indicators(style) - } - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => { - self.save_toast_delivery(delivery) - } - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() - } - }, - MouseAction::FocusWorkspace { ws_idx } => { - self.focus_workspace_idx_via_api(ws_idx) - } - MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx), - MouseAction::FocusPane { ws_idx, pane_id } => { - self.focus_pane_internal_via_api(ws_idx, pane_id) - } - MouseAction::FocusToastTarget => self.focus_toast_target_via_api(), - MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - } => self.move_workspace_via_api(source_ws_idx, insert_idx), - MouseAction::MoveWorkspaceBlock { params } => { - self.move_workspace_block_via_api(params) - } - MouseAction::MoveTab { - ws_idx, - source_tab_idx, - insert_idx, - } => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx), - MouseAction::SetSplitRatio { path, ratio } => { - self.set_split_ratio_via_api(path, ratio) - } - MouseAction::RenameModal(action) => { - self.apply_rename_mouse_action_via_api(action) - } - MouseAction::ConfirmCloseAccept => self.confirm_close_accept_via_api(), - MouseAction::ContextMenu { menu, idx } => { - self.apply_context_menu_action_via_api(menu, idx) - } - } - } - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self - .state - .selection - .as_ref() - .is_none_or(crate::selection::Selection::is_in_progress) - { - self.selection_highlight_clear_deadline = None; - } - } - if previous_settings_section != crate::app::state::SettingsSection::Integrations - && self.state.settings.section == crate::app::state::SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - if self.state.agent_panel_sort != previous_agent_panel_sort { - self.save_agent_panel_sort(self.state.agent_panel_sort); - } - - self.dispatch_pending_clipboard_write(); - - // Sync autoscroll deadline with state (mouse handler may have - // set or cleared selection_autoscroll during handle_mouse). - if self.state.selection_autoscroll.is_none() { - self.selection_autoscroll_deadline = None; - } else if self.selection_autoscroll_deadline.is_none() { - self.selection_autoscroll_deadline = - Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL); - } - } - - fn handle_popup_mouse(&mut self, mouse: MouseEvent) { - let Some((_outer, inner)) = - crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area) - else { - return; - }; - if mouse.column < inner.x - || mouse.column >= inner.x.saturating_add(inner.width) - || mouse.row < inner.y - || mouse.row >= inner.y.saturating_add(inner.height) - { - return; - } - let Some(rt) = self.popup_runtime() else { - self.close_popup_pane(); - return; - }; - let position = crate::input::mouse::Position::Cell { - column: mouse.column.saturating_sub(inner.x), - row: mouse.row.saturating_sub(inner.y), - }; - let bytes = match mouse.kind { - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::ScrollLeft - | MouseEventKind::ScrollRight => match rt.wheel_routing() { - Some(crate::pane::WheelRouting::MouseReport) => { - rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.encode_alternate_scroll(mouse.kind) - } - Some(crate::pane::WheelRouting::HostScroll) | None => { - let lines_per_notch = self.state.mouse_scroll_lines; - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), - MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), - _ => {} - } - return; - } - }, - MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) - } - MouseEventKind::Moved => rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers), - }; - let Some(bytes) = bytes else { - return; - }; - if !matches!(mouse.kind, MouseEventKind::Moved) { - rt.scroll_reset(); - } - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event"); - } - } - - fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) { - if !matches!(self.state.mode, Mode::Terminal | Mode::Resize) - || !matches!( - mouse.kind, - MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) - ) - { - return; - } - - let Some(pane_id) = self - .state - .pane_at(mouse.column, mouse.row) - .map(|info| info.id) - else { - return; - }; - let Some(ws_idx) = self.state.active else { - return; - }; - - // Focus through the runtime API before an application can consume its press. - self.focus_pane_internal_via_api(ws_idx, pane_id); - } - - fn handle_modified_url_click( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - ) -> bool { - self.handle_modified_url_click_with(source_id, mouse, crate::platform::open_url) - } - - fn handle_modified_url_click_with( - &mut self, - source_id: super::InputSourceId, - mouse: MouseEvent, - open_url: impl FnOnce(&str) -> std::io::Result>, - ) -> bool { - if self.state.mode != Mode::Terminal - || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - || !mouse.modifiers.contains(modified_url_click_modifier()) - { - return false; - } - - let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let viewport_row = mouse.row.saturating_sub(info.inner_rect.y); - let col = mouse.column.saturating_sub(info.inner_rect.x); - let Some(url) = - self.state - .url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col) - else { - return false; - }; - - let plugin_handled = match self.invoke_plugin_link_handler_for_url(&url, info.id) { - Ok(handled) => handled, - Err(err) => { - tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); - false - } - }; - if !plugin_handled && crate::app::actions::safe_web_url(&url).is_none() { - return false; - } - - self.last_pane_click = None; - self.pending_url_click_sources.insert(source_id); - if plugin_handled { - return true; - } - match open_url(&url) { - Ok(Some(child)) => self.detached_process_children.push(child), - Ok(None) => {} - Err(err) => { - tracing::warn!(err = %err, url = %url, "failed to open pane URL"); - } - } - true - } - - fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool { - // A pane press stops being a double-click candidate once it becomes - // a drag or completes as a real text selection. - match mouse.kind { - MouseEventKind::Drag(MouseButton::Left) => { - self.last_pane_click = None; - return false; - } - MouseEventKind::Up(MouseButton::Left) - if self - .state - .selection - .as_ref() - .is_some_and(|selection| selection.is_visible()) => - { - self.last_pane_click = None; - return false; - } - _ => {} - } - - // Only terminal-pane left-clicks can start this gesture; other clicks - // should keep their existing mouse behavior and clear stale candidates. - let Some(click) = self.pane_click_candidate(mouse) else { - return false; - }; - - // Require the second click to land near the first click in the same pane - // and within the double-click window so adjacent interactions do not select a word. - if !self.take_pane_double_click(click) { - return false; - } - - self.select_double_clicked_word(click) - } - - fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return None; - } - - if !mouse.modifiers.is_empty() { - self.last_pane_click = None; - return None; - } - - if self.state.mode != Mode::Terminal { - self.last_pane_click = None; - return None; - } - - let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { - self.last_pane_click = None; - return None; - }; - - Some(PaneClickState { - pane_id: info.id, - viewport_row: mouse.row - info.inner_rect.y, - col: mouse.column - info.inner_rect.x, - at: std::time::Instant::now(), - }) - } - - fn take_pane_double_click(&mut self, click: PaneClickState) -> bool { - if !self - .last_pane_click - .is_some_and(|last| last.is_double_click_for(click)) - { - self.last_pane_click = Some(click); - return false; - } - - self.last_pane_click = None; - true - } - - fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool { - let selected = self.state.select_word_at_pane_cell( - &self.terminal_runtimes, - click.pane_id, - click.viewport_row, - click.col, - ); - if selected { - self.selection_highlight_clear_deadline = self - .state - .copy_on_select - .then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION); - } - selected - } -} - -pub(crate) fn is_modal_paste_shortcut(key: &KeyEvent) -> bool { - if !matches!(key.code, KeyCode::Char('v' | 'V')) { - return false; - } - - #[cfg(target_os = "macos")] - { - key.modifiers.contains(KeyModifiers::SUPER) || key.modifiers.contains(KeyModifiers::CONTROL) - } - - #[cfg(not(target_os = "macos"))] - { - key.modifiers.contains(KeyModifiers::CONTROL) - } -} - -pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { - match state.mode { - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane | Mode::NewLinkedWorktree => { - true - } - Mode::OpenExistingWorktree => state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused), - Mode::Navigator => state.navigator.search_focused, - Mode::KeybindHelp => state.keybind_help.search_focused, - Mode::Copy => state - .copy_mode - .as_ref() - .is_some_and(|copy_mode| copy_mode.search.prompt.is_some()), - _ => false, - } -} - -// --------------------------------------------------------------------------- -// Mouse handling -// --------------------------------------------------------------------------- - -// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App -impl AppState { - #[cfg(test)] - pub(crate) fn split_pane( - &mut self, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - direction: Direction, - ) { - // Actual PTY spawning happens in Workspace::split_focused - // which needs events channel — this is called from navigate_key - // where we don't have async context, so the workspace handles it - let (rows, cols) = self.estimate_pane_size(); - let new_rows = (rows / 2).max(4); - let new_cols = (cols / 2).max(10); - - let follow_cwd = self - .active - .and_then(|i| self.workspaces.get(i)) - .and_then(|ws| { - let tab = ws.active_tab()?; - let terminal_id = tab.terminal_id(tab.layout.focused())?; - super::creation::launch_cwd_for_terminal( - terminal_id, - &self.terminals, - terminal_runtimes, - ) - }); - let cwd = Some(super::creation::resolve_new_terminal_cwd( - &self.new_terminal_cwd, - follow_cwd, - )); - - let previous_focus = self.current_pane_focus_target(); - if let Some(ws_idx) = self.active { - let Some(ws) = self.workspaces.get_mut(ws_idx) else { - return; - }; - if let Ok(new_pane) = ws.split_focused( - direction, - new_rows, - new_cols, - cwd, - self.pane_scrollback_limit_bytes, - self.host_terminal_theme, - self.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode), - Vec::new(), - ) { - let new_id = new_pane.pane_id; - terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.remove_alias_shadowed_by_new_pane(new_id); - self.terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - self.record_pane_focus_change(previous_focus, ws_idx, new_id); - self.mark_session_dirty(); - self.mode = Mode::Terminal; - } - } - } -} - -#[cfg(test)] -fn state_with_workspaces(names: &[&str]) -> AppState { - let mut state = AppState::test_new(); - state.workspaces = names - .iter() - .map(|name| crate::workspace::Workspace::test_new(name)) - .collect(); - if !state.workspaces.is_empty() { - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - } - state -} - -#[cfg(test)] -fn app_for_mouse_test() -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.mode = Mode::Terminal; - app.state.update_available = None; - app.state.latest_release_notes_available = false; - app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20); - app -} - -#[cfg(test)] -fn mouse( - kind: crossterm::event::MouseEventKind, - col: u16, - row: u16, -) -> crossterm::event::MouseEvent { - crossterm::event::MouseEvent { - kind, - column: col, - row, - modifiers: crossterm::event::KeyModifiers::empty(), - } -} - -#[cfg(test)] -fn numbered_lines_bytes(count: usize) -> Vec { - (0..count) - .map(|i| format!("{i:06}\r\n")) - .collect::() - .into_bytes() -} - -#[cfg(test)] -fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - crate::persist::capture( - &state.workspaces, - &state.terminals, - &terminal_runtimes, - state.active, - state.selected, - state.sidebar_width, - state.sidebar_section_split, - state.collapsed_space_keys.clone(), - ) -} - -#[cfg(test)] -fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option { - match &snapshot.workspaces.first()?.tabs.first()?.layout { - crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), - crate::persist::LayoutSnapshot::Pane(_) => None, - } -} - -#[cfg(test)] -fn unique_temp_path(name: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) -} - -#[cfg(test)] -#[cfg(unix)] -fn wait_for_file(path: &std::path::Path) -> String { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(content) = std::fs::read_to_string(path) { - if !content.is_empty() { - return content; - } - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("timed out waiting for {}", path.display()); -} - -#[cfg(test)] -#[cfg(unix)] -async fn wait_for_detached_process_reap(app: &mut App, pid: u32) -> bool { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline { - app.reap_finished_detached_processes(); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - app.reap_finished_detached_processes(); - !crate::platform::process_exists(pid) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_app() -> App { - App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), - ) - } - - #[tokio::test] - async fn paste_routes_to_rename_modal_input() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.name_input = "2".into(); - app.state.name_input_replace_on_type = true; - - app.handle_paste("feature/logs".into()).await; - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[tokio::test] - async fn paste_routes_to_keybind_help_query_only_when_searching() { - let mut app = test_app(); - app.state.mode = Mode::KeybindHelp; - app.handle_paste("ignored".into()).await; - assert!(app.state.keybind_help.query.is_empty()); - - app.state.keybind_help.search_focused = true; - app.state.keybind_help.scroll = 3; - app.handle_paste("work\nspace".into()).await; - - assert_eq!(app.state.keybind_help.query, "workspace"); - assert_eq!(app.state.keybind_help.scroll, 0); - } - - #[tokio::test] - async fn paste_routes_to_new_linked_worktree_input() { - let mut app = test_app(); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(crate::app::state::WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.handle_paste("feature/linear-302".into()).await; - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[test] - fn modal_paste_shortcut_matches_platform_primary_v() { - #[cfg(target_os = "macos")] - let modifiers = KeyModifiers::SUPER; - #[cfg(not(target_os = "macos"))] - let modifiers = KeyModifiers::CONTROL; - - assert!(is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('v'), - modifiers - ))); - assert!(is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('V'), - modifiers | KeyModifiers::SHIFT - ))); - assert!(!is_modal_paste_shortcut(&KeyEvent::new( - KeyCode::Char('v'), - KeyModifiers::ALT - ))); - } - - #[test] - fn modal_paste_target_is_active_only_for_text_inputs() { - let mut state = AppState::test_new(); - - state.mode = Mode::RenameTab; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::Navigator; - state.navigator.search_focused = false; - assert!(!modal_paste_target_active(&state)); - state.navigator.search_focused = true; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::KeybindHelp; - state.keybind_help.search_focused = false; - assert!(!modal_paste_target_active(&state)); - state.keybind_help.search_focused = true; - assert!(modal_paste_target_active(&state)); - - state.mode = Mode::ConfirmClose; - assert!(!modal_paste_target_active(&state)); - } -} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs deleted file mode 100644 index e710459b..00000000 --- a/src/app/input/modal.rs +++ /dev/null @@ -1,2421 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -#[cfg(test)] -use ratatui::layout::Direction; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{ - AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, - }, - App, - }, - input::TerminalKey, - layout::NavDirection, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalAction { - Continue, - Save, - Clear, - Cancel, - Confirm, - Apply, - Close, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalKeyBinding { - Enter, - Esc, - CtrlC, -} - -impl ModalKeyBinding { - fn matches(self, key: &KeyEvent) -> bool { - match self { - Self::Enter => key.code == KeyCode::Enter, - Self::Esc => key.code == KeyCode::Esc, - Self::CtrlC => { - key.code == KeyCode::Char('c') - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - } - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(super) struct ModalActionSpec { - pub action: A, - pub bindings: &'static [ModalKeyBinding], -} - -pub(super) fn modal_action_from_key( - key: &KeyEvent, - specs: &[ModalActionSpec], -) -> Option { - specs - .iter() - .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) - .map(|spec| spec.action) -} - -pub(super) fn modal_action_from_buttons( - col: u16, - row: u16, - buttons: &[(Rect, A)], -) -> Option { - buttons.iter().find_map(|(rect, action)| { - (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) - .then_some(*action) - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GlobalMenuAction { - Detach, - WhatsNew, - Keybinds, - ReloadConfig, - Settings, -} - -pub(super) fn global_menu_actions(state: &AppState) -> Vec { - let mut actions = vec![ - GlobalMenuAction::Settings, - GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadConfig, - ]; - if state.update_available.is_some() || state.latest_release_notes_available { - actions.push(GlobalMenuAction::WhatsNew); - } - actions.push(GlobalMenuAction::Detach); - actions -} - -pub(super) fn open_global_menu(state: &mut AppState) { - state.global_menu = MenuListState::new(0); - state.mode = Mode::GlobalMenu; -} - -pub(super) fn open_keybind_help(state: &mut AppState) { - state.keybind_help.scroll = 0; - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.mode = Mode::KeybindHelp; -} - -fn open_update_release_notes(state: &mut AppState) { - let Some(notes) = crate::release_notes::load_latest() else { - return; - }; - - state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: notes.version, - body: notes.body, - scroll: 0, - preview: notes.preview, - }); - state.mode = Mode::ReleaseNotes; -} - -pub(super) fn request_detach(state: &mut AppState) { - if state.detach_exits { - state.should_quit = true; - } else { - state.detach_requested = true; - } -} - -pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { - match action { - GlobalMenuAction::Detach => { - leave_modal(state); - request_detach(state); - } - GlobalMenuAction::WhatsNew => open_update_release_notes(state), - GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadConfig => { - state.request_reload_config = true; - leave_modal(state); - } - GlobalMenuAction::Settings => super::settings::open_settings(state), - } -} - -pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { - let actions = global_menu_actions(state); - match key.code { - KeyCode::Esc => leave_modal(state), - KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), - KeyCode::Enter => { - if let Some(action) = actions.get(state.global_menu.highlighted).copied() { - apply_global_menu_action(state, action); - } - } - _ => {} - } -} - -pub(crate) fn handle_navigator_key( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - if state.navigator.search_focused { - match key.code { - KeyCode::Esc => { - state.navigator.search_focused = false; - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace => { - state.navigator.state_filter = None; - state.navigator.query.pop(); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1), - KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1), - KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => - { - insert_navigator_search_text(state, terminal_runtimes, &c.to_string()); - } - _ => {} - } - return; - } - - match key.code { - KeyCode::Esc => { - leave_modal(state); - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('/') => { - state.navigator.state_filter = None; - state.navigator.search_focused = true; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace if state.navigator.state_filter.is_some() => { - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('a') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('b') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('w') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('i') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Idle); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('d') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Done); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('j') | KeyCode::Down if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('k') | KeyCode::Up if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - (state.navigator_body_rect().height / 2).max(1) as isize, - ), - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - -((state.navigator_body_rect().height / 2).max(1) as isize), - ), - KeyCode::Char(' ') => state.toggle_selected_navigator_workspace_from(terminal_runtimes), - KeyCode::Home => { - state.navigator.selected = 0; - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - KeyCode::End | KeyCode::Char('G') => { - state.navigator.selected = state - .navigator_rows_from(terminal_runtimes) - .len() - .saturating_sub(1); - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - _ => {} - } -} - -pub(crate) fn insert_navigator_search_text( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - text: &str, -) { - if !state.navigator.search_focused { - return; - } - state.navigator.state_filter = None; - state.navigator.query.push_str(text); - state.select_first_navigator_match_from(terminal_runtimes); -} - -pub(crate) fn insert_keybind_help_query_text(state: &mut AppState, text: &str) { - if !state.keybind_help.search_focused { - return; - } - state - .keybind_help - .query - .extend(text.chars().filter(|ch| !ch.is_control())); - state.keybind_help.scroll = 0; -} - -pub(super) fn keybind_help_back(state: &mut AppState) { - if state.keybind_help.search_focused { - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.keybind_help.scroll = 0; - } else { - leave_modal(state); - } -} - -pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { - if state.keybind_help.search_focused { - let text_char = keybind_help_text_char(key.clone()); - match key.code { - KeyCode::Up => state.scroll_keybind_help(-1), - KeyCode::Down => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - KeyCode::Backspace => { - state.keybind_help.query.pop(); - state.keybind_help.scroll = 0; - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.keybind_help.query.clear(); - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ => { - if let Some(character) = text_char { - insert_keybind_help_query_text(state, &character.to_string()); - } - } - } - return; - } - - match key.code { - KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), - KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - _ if keybind_help_text_char(key.clone()) == Some('/') => { - state.keybind_help.search_focused = true; - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state), - _ => {} - } -} - -fn keybind_help_text_char(key: TerminalKey) -> Option { - if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - return None; - } - if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(character); - } - let KeyCode::Char(character) = key.code else { - return None; - }; - Some(character) -} - -pub(super) fn open_rename_workspace( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, -) { - state.pending_workspace_create_cwd = None; - state.selected = ws_idx; - state.rename_pane_target = None; - state.name_input = - state.workspaces[ws_idx].display_name_from(&state.terminals, terminal_runtimes); - state.name_input_replace_on_type = false; - state.mode = Mode::RenameWorkspace; -} - -pub(crate) fn open_new_workspace_dialog(state: &mut AppState, cwd: std::path::PathBuf) { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = Some(cwd); - state.rename_pane_target = None; - state.name_input = suggested_name; - state.name_input_replace_on_type = true; - state.mode = Mode::RenameWorkspace; -} - -pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { - if let Some(name) = ws.active_tab_display_name() { - state.name_input = name; - state.name_input_replace_on_type = replace_on_type; - state.mode = Mode::RenameTab; - } - } -} - -pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::PaneId) { - let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) else { - return; - }; - let Some(pane) = ws.pane_state(pane_id) else { - return; - }; - let terminal = state.terminals.get(&pane.attached_terminal_id); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = Some(pane_id); - state.name_input = terminal - .and_then(|t| t.manual_label.clone()) - .unwrap_or_default(); - state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none(); - state.mode = Mode::RenamePane; -} - -fn workspace_create_label(input: &str, suggested_name: &str) -> Option { - let name = input.trim(); - (!name.is_empty() && name != suggested_name).then(|| name.to_string()) -} - -fn next_new_tab_default_name(state: &AppState) -> String { - state - .active - .and_then(|i| state.workspaces.get(i)) - .map(|ws| (ws.tabs.len() + 1).to_string()) - .unwrap_or_else(|| "1".to_string()) -} - -pub(super) fn open_new_tab_dialog(state: &mut AppState) { - state.creating_new_tab = true; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input = next_new_tab_default_name(state); - state.name_input_replace_on_type = true; - state.mode = Mode::RenameTab; -} - -pub(super) fn leave_modal(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } -} - -pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { - action: ModalAction::Continue, - bindings: &[ModalKeyBinding::Enter], -}]; - -pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], -}]; - -pub(super) const RENAME_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Clear, - bindings: &[ModalKeyBinding::CtrlC], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Confirm, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Apply, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -#[cfg(test)] -pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { - match action { - ModalAction::Save => { - let new_name = if state.name_input.trim().is_empty() { - state.name_input.clone() - } else { - state.name_input.trim().to_string() - }; - match state.mode { - Mode::RenameWorkspace - if state.pending_workspace_create_cwd.is_none() - && !state.workspaces.is_empty() - && !new_name.is_empty() => - { - let workspace_id = state.workspaces[state.selected].id.clone(); - state.workspaces[state.selected].set_custom_name(new_name); - crate::logging::workspace_renamed(&workspace_id); - state.mark_session_dirty(); - } - Mode::RenameTab if state.creating_new_tab => { - state.request_new_tab = true; - let default_name = next_new_tab_default_name(state); - state.requested_new_tab_name = - if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - } - Mode::RenameTab => { - if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - let workspace_id = ws.id.clone(); - let active_tab = ws.active_tab; - let keep_auto_name = ws - .tabs - .get(active_tab) - .is_some_and(|tab| tab.is_auto_named()) - && ws - .tab_display_name(active_tab) - .is_some_and(|name| new_name == name); - if let Some(tab) = ws.active_tab_mut() { - if !new_name.is_empty() && !keep_auto_name { - tab.set_custom_name(new_name); - let tab_id = ws - .public_tab_number(active_tab) - .map(|number| { - crate::workspace::public_tab_id_for_number( - &workspace_id, - number, - ) - }) - .unwrap_or_else(|| workspace_id.clone()); - crate::logging::tab_renamed(&workspace_id, &tab_id); - state.mark_session_dirty(); - } - } - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) - { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_manual_label(new_name); - state.mark_session_dirty(); - } - } - } - } - } - _ => {} - } - state.creating_new_tab = false; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - ModalAction::Clear => { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } - ModalAction::Cancel => { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - _ => {} - } -} - -fn clear_rename_input(state: &mut AppState) { - state.name_input.clear(); - state.name_input_replace_on_type = false; -} - -pub(crate) fn insert_rename_input_text(state: &mut AppState, text: &str) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } - state.name_input.push_str(text); -} - -fn delete_rename_input_char(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } else { - state.name_input.pop(); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RenameWordDeleteClass { - Word, - Separator, -} - -fn rename_word_delete_class(ch: char) -> RenameWordDeleteClass { - if ch.is_alphanumeric() || ch == '_' { - RenameWordDeleteClass::Word - } else { - RenameWordDeleteClass::Separator - } -} - -fn delete_rename_input_word(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - return; - } - - while state - .name_input - .chars() - .last() - .is_some_and(char::is_whitespace) - { - state.name_input.pop(); - } - - let Some(class) = state - .name_input - .chars() - .last() - .map(rename_word_delete_class) - else { - return; - }; - - while state - .name_input - .chars() - .last() - .is_some_and(|ch| !ch.is_whitespace() && rename_word_delete_class(ch) == class) - { - state.name_input.pop(); - } -} - -fn handle_rename_edit_key(state: &mut AppState, key: KeyEvent) { - match key.code { - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - clear_rename_input(state); - } - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => { - clear_rename_input(state); - } - KeyCode::Backspace - if key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT) => - { - delete_rename_input_word(state); - } - KeyCode::Char('h' | 'w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - delete_rename_input_word(state); - } - KeyCode::Backspace => delete_rename_input_char(state), - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - insert_rename_input_text(state, &c.to_string()); - } - _ => {} - } -} - -#[cfg(test)] -pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - apply_rename_action(state, action); - return; - } - - handle_rename_edit_key(state, key); -} - -#[cfg(test)] -pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } - return; - } - - match key.code { - KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), - _ => {} - } -} - -pub(super) fn open_confirm_close(state: &mut AppState) { - state.begin_workspace_close_confirmation(state.selected); -} - -#[cfg(test)] -pub(super) fn confirm_close_accept(state: &mut AppState) { - if let Some(ws_idx) = state.take_confirmed_workspace_close_index() { - state.selected = ws_idx; - state.close_selected_workspace(); - } - if state.workspaces.is_empty() { - state.mode = Mode::Navigate; - } else { - state.mode = Mode::Terminal; - } -} - -pub(super) fn confirm_close_cancel(state: &mut AppState) { - state.confirm_close_workspace_id = None; - state.mode = Mode::Navigate; -} - -#[cfg(test)] -pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => confirm_close_accept(state), - Some(ModalAction::Cancel) => confirm_close_cancel(state), - _ => {} - } -} - -#[cfg(test)] -pub(super) fn apply_context_menu_action( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - menu: ContextMenuState, - idx: usize, -) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - state.request_new_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - state.request_open_existing_worktree = Some(ws_idx); - leave_modal(state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - state.collapsed_space_keys.remove(&key); - } else { - state.collapsed_space_keys.insert(key); - } - state.mark_session_dirty(); - } - leave_modal(state); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => { - open_rename_workspace(state, terminal_runtimes, ws_idx); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - state.selected = ws_idx; - if state.confirm_close { - open_confirm_close(state); - } else { - state.close_selected_workspace(); - state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_new_tab_dialog(state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_rename_active_tab(state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if !state.close_tab() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.clear_manual_label(); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - }, - Some("Swap with focused pane"), - ) => { - if let Some(source_pane_id) = source_pane_id { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if let Some(tab) = state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - if tab.layout.swap_panes(source_pane_id, pane_id) { - tab.layout.focus_pane(source_pane_id); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split right"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Horizontal); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split down"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Vertical); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Zoom"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.toggle_zoom(); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Close pane"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - if !state.close_pane() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(state), - } -} - -#[cfg(test)] -pub(crate) fn handle_context_menu_key( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - match key.code { - KeyCode::Esc => { - state.context_menu = None; - leave_modal(state); - } - KeyCode::Up => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = state.context_menu.take() { - let idx = menu.list.highlighted; - apply_context_menu_action(state, terminal_runtimes, menu, idx); - } - } - _ => {} - } -} - -impl App { - pub(crate) fn handle_rename_key_via_api(&mut self, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - self.apply_rename_mouse_action_via_api(action); - return; - } - - handle_rename_edit_key(&mut self.state, key); - } - - fn save_rename_modal_via_api(&mut self) { - let new_name = if self.state.name_input.trim().is_empty() { - self.state.name_input.clone() - } else { - self.state.name_input.trim().to_string() - }; - - match self.state.mode { - Mode::RenameWorkspace => { - if let Some(cwd) = self.state.pending_workspace_create_cwd.take() { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let label = workspace_create_label(&new_name, &suggested_name); - self.runtime_workspace_create( - "tui.workspace.create_named", - crate::api::schema::WorkspaceCreateParams { - cwd: Some(cwd.display().to_string()), - focus: true, - label, - env: Default::default(), - }, - ); - } else if !self.state.workspaces.is_empty() && !new_name.is_empty() { - let workspace_id = self.public_workspace_id(self.state.selected); - self.runtime_workspace_rename( - "tui.workspace.rename", - crate::api::schema::WorkspaceRenameParams { - workspace_id, - label: new_name, - }, - ); - } - } - Mode::RenameTab if self.state.creating_new_tab => { - let default_name = next_new_tab_default_name(&self.state); - let label = if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - self.runtime_tab_create( - "tui.tab.create_named", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - } - Mode::RenameTab if !new_name.is_empty() => { - let Some(ws_idx) = self.state.active else { - cancel_rename_modal(&mut self.state); - return; - }; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let keep_auto_name = self.state.workspaces[ws_idx] - .tabs - .get(tab_idx) - .is_some_and(|tab| tab.is_auto_named()) - && self.state.workspaces[ws_idx] - .tab_display_name(tab_idx) - .is_some_and(|name| new_name == name); - if !keep_auto_name { - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - self.runtime_tab_rename( - "tui.tab.rename", - crate::api::schema::TabRenameParams { - tab_id, - label: new_name, - }, - ); - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = - (self.state.active, self.state.rename_pane_target) - { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.rename", - crate::api::schema::PaneRenameParams { - pane_id, - label: Some(new_name), - }, - ); - } - } - } - _ => {} - } - - cancel_rename_modal(&mut self.state); - } - - pub(super) fn apply_rename_mouse_action_via_api(&mut self, action: ModalAction) { - match action { - ModalAction::Save => self.save_rename_modal_via_api(), - ModalAction::Clear => { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - ModalAction::Cancel => cancel_rename_modal(&mut self.state), - _ => {} - } - } - - pub(super) fn confirm_close_accept_via_api(&mut self) { - if let Some(ws_idx) = self.state.take_confirmed_workspace_close_index() { - self.close_workspace_idx_with_group_via_api(ws_idx); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn handle_resize_key_via_api(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || self.state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - return; - } - - let direction = match key.code { - KeyCode::Char('h') | KeyCode::Left => Some(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => Some(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => Some(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => Some(NavDirection::Up), - _ => None, - }; - if let Some(direction) = direction { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: super::navigate::api_pane_direction(direction), - amount: None, - }, - ); - } - } - - pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => { - self.confirm_close_accept_via_api(); - } - Some(ModalAction::Cancel) => confirm_close_cancel(&mut self.state), - _ => {} - } - } - - pub(crate) fn handle_context_menu_key_via_api(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.context_menu = None; - leave_modal(&mut self.state); - } - KeyCode::Up => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = self.state.context_menu.take() { - let idx = menu.list.highlighted; - self.apply_context_menu_action_via_api(menu, idx); - } - } - _ => {} - } - } - - pub(crate) fn apply_context_menu_action_via_api(&mut self, menu: ContextMenuState, idx: usize) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - self.state.collapsed_space_keys.remove(&key); - } else { - self.state.collapsed_space_keys.insert(key); - } - self.state.mark_session_dirty(); - } - leave_modal(&mut self.state); - } - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => open_rename_workspace(&mut self.state, &self.terminal_runtimes, ws_idx), - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - self.state.selected = ws_idx; - if self.state.confirm_close { - open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - self.state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_new_tab_dialog(&mut self.state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_rename_active_tab(&mut self.state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - if !self.close_active_tab_via_api_requires_confirmation() { - leave_modal(&mut self.state); - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(&mut self.state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.clear_name", - crate::api::schema::PaneRenameParams { - pane_id, - label: None, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_input_set( - "tui.pane.input.set", - crate::api::schema::PaneInputSetParams { - pane_id, - right_click: if action == "Send right-clicks to pane" { - crate::api::schema::PaneRightClickTarget::Pane - } else { - crate::api::schema::PaneRightClickTarget::Herdr - }, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - pane_id, - source_pane_id: Some(source_pane_id), - .. - }, - Some("Swap with focused pane"), - ) => { - let source_public_id = self.public_pane_id(ws_idx, source_pane_id); - let target_public_id = self.public_pane_id(ws_idx, pane_id); - if let (Some(source_public_id), Some(target_public_id)) = - (source_public_id, target_public_id) - { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_public_id), - target_pane_id: Some(target_public_id), - }, - ); - self.focus_pane_internal_via_api(ws_idx, source_pane_id); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split right"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split down"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Zoom"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.zoom_focused_pane_via_api(); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Close pane"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - if !self.close_focused_pane_via_api_requires_confirmation() { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(&mut self.state), - } - } -} - -fn cancel_rename_modal(state: &mut AppState) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); -} - -impl AppState { - pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option { - let rect = self.global_menu_rect(); - if col <= rect.x - || col >= rect.x + rect.width.saturating_sub(1) - || row <= rect.y - || row >= rect.y + rect.height.saturating_sub(1) - { - return None; - } - let idx = (row - rect.y - 1) as usize; - global_menu_actions(self).get(idx).copied() - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use ratatui::layout::Rect; - - use super::super::{capture_snapshot, state_with_workspaces}; - use super::*; - use crate::workspace::Workspace; - - fn config_env_lock() -> &'static std::sync::Mutex<()> { - crate::config::test_config_env_lock() - } - - fn temp_config_path(name: &str) -> std::path::PathBuf { - let unique = format!( - "herdr-modal-{name}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - std::env::temp_dir().join(unique).join("config.toml") - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn workspace_create_label_preserves_auto_name_for_suggestion_or_blank() { - assert_eq!(workspace_create_label("project", "project"), None); - assert_eq!(workspace_create_label("", "project"), None); - assert_eq!(workspace_create_label(" ", "project"), None); - assert_eq!( - workspace_create_label(" logs ", "project").as_deref(), - Some("logs") - ); - } - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[test] - fn custom_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn direct_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r"); - - handle_resize_key( - &mut state, - TerminalKey::new( - KeyCode::Char('r'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn resize_key_exit_matches_enhanced_shifted_punctuation() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn detach_requests_client_detach_in_persistence_mode() { - let mut state = state_with_workspaces(&["test"]); - state.detach_exits = false; - - request_detach(&mut state); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } - - #[test] - fn detach_exits_in_no_session_mode() { - let mut state = state_with_workspaces(&["test"]); - state.detach_exits = true; - - request_detach(&mut state); - - assert!(state.should_quit); - assert!(!state.detach_requested); - } - - #[test] - fn global_menu_whats_new_opens_saved_release_notes() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("whats-new-saved-release-notes"); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - crate::release_notes::save_pending(env!("CARGO_PKG_VERSION"), "### Changed\n- Menu") - .unwrap(); - - let mut state = state_with_workspaces(&["test"]); - state.latest_release_notes_available = true; - - assert!(global_menu_actions(&state).contains(&GlobalMenuAction::WhatsNew)); - - apply_global_menu_action(&mut state, GlobalMenuAction::WhatsNew); - - assert_eq!(state.mode, Mode::ReleaseNotes); - assert_eq!( - state - .release_notes - .as_ref() - .map(|notes| notes.body.as_str()), - Some("### Changed\n- Menu") - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn rename_modal_keyboard_and_mouse_share_actions() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "hello".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "renamed".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.workspaces[0].display_name(), "renamed"); - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].custom_name.as_deref(), - Some("renamed") - ); - - state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - state.view.terminal_area = Rect::new(26, 0, 80, 20); - state.mode = Mode::RenameWorkspace; - state.name_input = "mouse".into(); - let inner = state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); - assert_eq!(action, Some(ModalAction::Save)); - } - - #[test] - fn tab_rename_updates_captured_snapshot() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "logs".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].tabs[0].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn rename_cancel_returns_to_terminal_when_workspace_is_active() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "test".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_first_type() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "n"); - assert!(!state.name_input_replace_on_type); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "ne"); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_paste() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - insert_rename_input_text(&mut state, "feature/logs"); - - assert_eq!(state.name_input, "feature/logs"); - assert!(!state.name_input_replace_on_type); - - insert_rename_input_text(&mut state, "-copy"); - - assert_eq!(state.name_input, "feature/logs-copy"); - } - - #[test] - fn rename_modal_handles_line_editing_shortcuts() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website zero".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "website zer"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website "); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "website zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_does_not_insert_modified_shortcut_chars() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT), - ); - assert_eq!(state.name_input, "websiteZ"); - } - - #[test] - fn keybind_help_slash_focuses_filter_and_preserves_vim_scroll() { - let mut state = state_with_workspaces(&["test"]); - state.keybind_help.query = "stale".into(); - state.keybind_help.search_focused = true; - state.view.terminal_area = Rect::new(0, 0, 100, 30); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 1); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 0); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - for character in "work".chars() { - state.keybind_help.scroll = 2; - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char(character), KeyModifiers::empty()), - ); - } - - assert!(state.keybind_help.search_focused); - assert_eq!(state.keybind_help.query, "work"); - assert_eq!(state.keybind_help.scroll, 0); - } - - #[test] - fn keybind_help_query_supports_backspace_clear_and_sanitized_paste() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - insert_keybind_help_query_text(&mut state, "work\nspace"); - assert_eq!(state.keybind_help.query, "workspace"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.query, "workspac"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.keybind_help.query.is_empty()); - } - - #[test] - fn keybind_help_escape_leaves_search_before_closing() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - state.keybind_help.search_focused = true; - state.keybind_help.query = "work".into(); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - assert!(!state.keybind_help.search_focused); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn enhanced_shifted_slash_focuses_keybind_help_filter() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('7'), KeyModifiers::SHIFT) - .with_shifted_codepoint('/' as u32), - ); - - assert!(state.keybind_help.search_focused); - } - - #[test] - fn enhanced_shifted_question_mark_closes_keybind_help_when_not_searching() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.keybind_help.query, "?"); - } - - #[test] - fn navigator_search_accepts_pasted_text_when_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert_eq!(state.navigator.query, "beta"); - assert_eq!(state.navigator.state_filter, None); - } - - #[test] - fn navigator_search_ignores_paste_when_search_is_not_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = false; - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert!(state.navigator.query.is_empty()); - } - - #[test] - fn navigator_empty_search_escape_returns_to_commands() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - - assert_eq!( - state.navigator.state_filter, - Some(NavigatorStateFilter::Working) - ); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_search_escape_blurs_then_next_escape_closes() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.query = "a".into(); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.selected, 1); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.query, "al"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_ignores_modified_j_and_k() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.selected = 1; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - } - - #[test] - fn open_rename_active_tab_can_prefill_default_new_tab_name() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, true); - - assert_eq!(state.mode, Mode::RenameTab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - } - - #[test] - fn cancel_new_tab_dialog_leaves_workspace_unchanged() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn saving_new_tab_dialog_requests_creation_with_name() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - state.name_input = "logs".into(); - state.name_input_replace_on_type = false; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); - } - - #[test] - fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn closing_first_auto_tab_compacts_remaining_auto_tab_label_and_next_prompt() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); - state.request_new_tab = false; - state.requested_new_tab_name = None; - - state.workspaces[0].close_tab(0); - state.workspaces[0].switch_tab(0); - - assert_eq!( - state.workspaces[0].tab_display_name(0).as_deref(), - Some("1") - ); - assert!(state.workspaces[0].tabs[0].custom_name.is_none()); - - open_new_tab_dialog(&mut state); - assert_eq!(state.name_input, "2"); - } - - #[test] - fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, false); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.workspaces[0].tabs[1].custom_name.is_none()); - assert_eq!( - state.workspaces[0].tab_display_name(1).as_deref(), - Some("2") - ); - } - - #[test] - fn confirm_close_keyboard_actions_are_direct_not_focused() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 1; - open_confirm_close(&mut state); - - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Navigate); - assert_eq!(state.workspaces.len(), 2); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces.len(), 1); - } - - #[test] - fn confirm_close_for_linked_worktree_closes_workspace_only() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn context_menu_close_group_opens_group_close_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - - confirm_close_accept(&mut state); - - assert!(state.workspaces.is_empty()); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn context_menu_toggles_pane_right_click_passthrough() { - let mut app = app_with_test_workspaces(&["main"]); - app.state.active = Some(0); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Send right-clicks to pane") - .unwrap(); - app.apply_context_menu_action_via_api(menu, idx); - - assert!( - app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .right_click_passthrough - ); - } - - #[test] - fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let pane_id = state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn api_confirm_close_accept_closes_parent_worktree_group() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn api_confirm_close_accept_keeps_the_original_workspace_target() { - let mut app = app_with_test_workspaces(&["main", "issue", "other"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.focus_workspace_idx_via_api(2); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.mode, Mode::ConfirmClose); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "other"); - assert_eq!( - app.event_hub - .events_after(0) - .iter() - .filter(|(_, event)| matches!( - event.event, - crate::api::schema::EventKind::WorkspaceClosed - )) - .count(), - 2 - ); - } - - #[test] - fn api_context_menu_close_tab_last_parent_group_workspace_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let menu = ContextMenuState { - kind: ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 0, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close") - .expect("close tab item"); - - app.apply_context_menu_action_via_api(menu, idx); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn api_context_menu_enter_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let close_idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - menu.list.highlighted = close_idx; - app.state.context_menu = Some(menu); - - app.handle_context_menu_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } -} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs deleted file mode 100644 index bf82126b..00000000 --- a/src/app/input/mouse.rs +++ /dev/null @@ -1,4778 +0,0 @@ -use bytes::Bytes; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::{Direction, Rect}; -use tracing::warn; - -use crate::{ - app::state::{ - AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, - WorkspacePressState, - }, - layout::{PaneInfo, SplitBorder}, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -use super::WheelRouting; -use super::{ - modal::{ - apply_global_menu_action, confirm_close_cancel, global_menu_actions, leave_modal, - modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, - }, - settings::SettingsAction, - ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, -}; - -pub(super) enum MouseAction { - NewWorkspace, - Settings(SettingsAction), - FocusWorkspace { - ws_idx: usize, - }, - FocusTab { - tab_idx: usize, - }, - FocusPane { - ws_idx: usize, - pane_id: crate::layout::PaneId, - }, - FocusToastTarget, - MoveWorkspace { - source_ws_idx: usize, - insert_idx: usize, - }, - MoveWorkspaceBlock { - params: crate::api::schema::WorkspaceMoveBlockParams, - }, - MoveTab { - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - }, - SetSplitRatio { - path: Vec, - ratio: f32, - }, - RenameModal(ModalAction), - ConfirmCloseAccept, - ContextMenu { - menu: ContextMenuState, - idx: usize, - }, -} - -enum MobileMouseResult { - Ignored, - Consumed, - Action(MouseAction), -} - -impl AppState { - pub(crate) fn handle_pane_mouse_only( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - if self.mode != Mode::Terminal { - return; - } - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return; - }; - - match mouse.kind { - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::ScrollLeft - | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - MouseEventKind::Moved => { - self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - } - - pub(super) fn handle_mouse( - &mut self, - terminal_runtimes: &mut TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - ) -> Option { - if self.mode == Mode::Onboarding { - self.handle_onboarding_mouse(mouse); - return None; - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return Some(MouseAction::FocusToastTarget); - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - { - return None; - } - - if self.mode == Mode::Settings { - return self.handle_settings_mouse(mouse).map(MouseAction::Settings); - } - - let launcher_enabled = self.view.layout != ViewLayout::Mobile - && !self.sidebar_collapsed - && matches!( - self.mode, - Mode::Terminal - | Mode::Navigate - | Mode::Resize - | Mode::GlobalMenu - | Mode::KeybindHelp - ); - let launcher = self.global_launcher_rect(); - let launcher_hit = launcher_enabled - && mouse.column >= launcher.x - && mouse.column < launcher.x + launcher.width - && mouse.row >= launcher.y - && mouse.row < launcher.y + launcher.height; - - if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { - let actions = global_menu_actions(self); - let hovered = self - .global_menu_item_at(mouse.column, mouse.row) - .and_then(|action| actions.iter().position(|item| *item == action)); - self.global_menu.hover(hovered); - return None; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { - if self.mode == Mode::GlobalMenu { - leave_modal(self); - } else { - open_global_menu(self); - } - return None; - } - - if self.mode == Mode::GlobalMenu { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { - apply_global_menu_action(self, action); - } else { - leave_modal(self); - } - } - return None; - } - - if self.mode == Mode::KeybindHelp { - return None; - } - - if self.view.layout == ViewLayout::Mobile { - match self.handle_mobile_mouse(mouse) { - MobileMouseResult::Ignored => {} - MobileMouseResult::Consumed => return None, - MobileMouseResult::Action(action) => return Some(action), - } - } - - let sidebar = self.view.sidebar_rect; - let in_sidebar = mouse.column >= sidebar.x - && mouse.column < sidebar.x + sidebar.width - && mouse.row >= sidebar.y - && mouse.row < sidebar.y + sidebar.height; - - if self.handle_right_click_passthrough(terminal_runtimes, source_id, mouse, in_sidebar) { - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(open) = &mut self.worktree_open { - open.select_previous_filtered(); - } - return None; - } - MouseEventKind::ScrollDown => { - if let Some(open) = &mut self.worktree_open { - open.select_next_filtered(); - } - return None; - } - _ => {} - } - } - - if matches!( - self.mode, - Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree - ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return None; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - - if self.mode == Mode::ConfirmClose { - let popup = self.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (confirm, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - return Some(MouseAction::ConfirmCloseAccept); - } - Some(ModalAction::Cancel) | None => confirm_close_cancel(self), - _ => {} - } - return None; - } - - if self.mode == Mode::NewLinkedWorktree { - if let Some(inner) = - crate::ui::new_linked_worktree_inner_rect(self.screen_rect()) - { - let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (create, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_create = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) => - { - self.worktree_create = None; - self.name_input.clear(); - self.name_input_replace_on_type = false; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - if let Some(open) = self.worktree_open.as_ref() { - if let Some(inner) = crate::ui::open_existing_worktree_inner_rect( - self.screen_rect(), - open.entries.len(), - ) { - let filtered = open.filtered_indices(); - let max_rows = - crate::ui::open_existing_worktree_max_visible_rows(inner); - let start = - crate::ui::open_existing_worktree_visible_start(open, max_rows); - if mouse.row == inner.y.saturating_add(1) - && mouse.column >= inner.x - && mouse.column < inner.x.saturating_add(inner.width) - { - if let Some(open) = &mut self.worktree_open { - open.search_focused = true; - } - return None; - } - let row_idx = if rect_contains(inner, mouse.column, mouse.row) { - mouse - .row - .checked_sub(inner.y.saturating_add(3)) - .map(usize::from) - .map(|row| row / 2) - .filter(|row| *row < max_rows) - .and_then(|row| filtered.get(start + row).copied()) - } else { - None - }; - if let Some(entry_idx) = row_idx { - if let Some(open) = &mut self.worktree_open { - open.selected = entry_idx; - } - self.request_submit_worktree_open = true; - return None; - } - - let (open_button, cancel) = - crate::ui::open_existing_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (open_button, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_open = true; - } - Some(ModalAction::Cancel) => { - self.worktree_open = None; - leave_modal(self); - } - _ => {} - } - } - } - return None; - } - - if self.mode == Mode::ConfirmRemoveWorktree { - if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) { - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let force_confirmation = self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.force_confirmation); - let (remove, cancel) = - crate::ui::remove_worktree_button_rects(inner, force_confirmation); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (remove, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_remove = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) => - { - self.worktree_remove = None; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if matches!( - self.mode, - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane - ) { - let action = self - .rename_modal_inner() - .map(crate::ui::rename_button_rects) - .and_then(|(save, clear, cancel)| { - modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (save, ModalAction::Save), - (clear, ModalAction::Clear), - (cancel, ModalAction::Cancel), - ], - ) - }) - .unwrap_or(ModalAction::Cancel); - return Some(MouseAction::RenameModal(action)); - } - - if self.mode == Mode::ContextMenu { - let item_idx = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = self.context_menu.take() { - if let Some(idx) = item_idx { - return Some(MouseAction::ContextMenu { menu, idx }); - } else { - leave_modal(self); - } - } - return None; - } - - if self.on_sidebar_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - self.set_manual_sidebar_width(mouse.column); - return None; - } - - if self.on_sidebar_section_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarSectionDivider, - }); - self.set_sidebar_section_split(mouse.row); - return None; - } - - if !in_sidebar { - if let Some(border) = self.find_border_at(mouse.column, mouse.row) { - let grab_offset = match border.direction { - Direction::Horizontal => border.pos.saturating_sub(mouse.column), - Direction::Vertical => border.pos.saturating_sub(mouse.row), - }; - self.drag = Some(DragState { - target: DragTarget::PaneSplit { - path: border.path.clone(), - direction: border.direction, - area: border.area, - grab_offset, - }, - }); - return None; - } - - if let Some((pane_id, target)) = - self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) - { - self.focus_pane(pane_id); - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - offset_from_bottom, - ); - } - } - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return None; - } - } - - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) { - return None; - } - - if self.on_tab_scroll_left_button(mouse.column, mouse.row) { - self.scroll_tabs_left(); - return None; - } - if self.on_tab_scroll_right_button(mouse.column, mouse.row) { - self.scroll_tabs_right(); - return None; - } - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.tab_presses.insert( - source_id, - TabPressState { - ws_idx, - tab_idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - if self.on_new_tab_button(mouse.column, mouse.row) { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - return None; - } - - if in_sidebar { - if self.on_sidebar_toggle(mouse.column, mouse.row) { - self.sidebar_collapsed = !self.sidebar_collapsed; - return None; - } - - if self.sidebar_collapsed { - if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { ws_idx: idx }); - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.collapsed_agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - return None; - } - - let new_button = self.sidebar_new_button_rect(); - let on_new_button = mouse.row >= new_button.y - && mouse.row < new_button.y + new_button.height - && mouse.column >= new_button.x - && mouse.column < new_button.x + new_button.width; - if on_new_button { - return Some(MouseAction::NewWorkspace); - } - - if let Some(target) = - self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - if let Some(card) = cards.iter().find(|card| { - let chevron = crate::ui::workspace_group_chevron_rect(card); - mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 - }) { - if let Some((key, collapsed)) = - crate::ui::workspace_parent_group_state(self, card.ws_idx) - { - if collapsed { - self.collapsed_space_keys.remove(&key); - } else { - self.collapsed_space_keys.insert(key); - } - self.mark_session_dirty(); - return None; - } - } - - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.workspace_presses.insert( - source_id, - WorkspacePressState { - ws_idx: idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - - if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) { - self.agent_panel_sort = match self.agent_panel_sort { - AgentPanelSort::Spaces => AgentPanelSort::Priority, - AgentPanelSort::Priority => AgentPanelSort::Spaces, - }; - self.agent_panel_scroll = 0; - self.mark_session_dirty(); - return None; - } - - if let Some(target) = - self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::AgentPanelScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return self.mouse_pane_focus_action(info.id); - } - - let (row, col) = ( - mouse.row - info.inner_rect.y, - mouse.column - info.inner_rect.x, - ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - self.pane_scroll_metrics(terminal_runtimes, info.id), - )); - return self.mouse_pane_focus_action(info.id); - } else if let Some(info) = self.view.pane_infos.iter().find(|p| { - mouse.column >= p.rect.x - && mouse.column < p.rect.x + p.rect.width - && mouse.row >= p.rect.y - && mouse.row < p.rect.y + p.rect.height - }) { - let id = info.id; - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return self.mouse_pane_focus_action(id); - } - } - - MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_some() { - self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); - return None; - } - - if (self.drag.is_none() || self.chrome_drag_owned_by_other(source_id)) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row); - let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); - if self.drag.is_none() { - if let Some(press) = self.workspace_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| { - ws.worktree_space() - .is_none_or(|space| !space.is_linked_worktree) - }); - if workspace_drop_target.is_some() - && can_reorder - && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::WorkspaceReorder { - source_id, - source_ws_idx: press.ws_idx, - drop_target: workspace_drop_target, - }, - }); - } - } else if let Some(press) = self.tab_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - // Require a real drop target before opening a reorder, - // so a report from off the tab bar cannot start a drag - // that has nowhere to land. - if tab_drop_index.is_some() - && delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::TabReorder { - source_id, - ws_idx: press.ws_idx, - source_tab_idx: press.tab_idx, - insert_idx: tab_drop_index, - }, - }); - } - } - } - - if let Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - drop_target, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id { - *drop_target = workspace_drop_target; - } - } else if let Some(DragState { - target: - DragTarget::TabReorder { - source_id: drag_source_id, - ws_idx, - insert_idx, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id && self.active == Some(*ws_idx) { - *insert_idx = tab_drop_index; - } - } else if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} - DragTarget::WorkspaceListScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::AgentPanelScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::PaneSplit { - path, - direction, - area, - grab_offset, - } => { - let ratio = match direction { - Direction::Horizontal => { - (mouse - .column - .saturating_add(*grab_offset) - .saturating_sub(area.x)) - as f32 - / area.width.max(1) as f32 - } - Direction::Vertical => { - (mouse - .row - .saturating_add(*grab_offset) - .saturating_sub(area.y)) - as f32 - / area.height.max(1) as f32 - } - }; - let ratio = ratio.clamp(0.1, 0.9); - let path = path.clone(); - return Some(MouseAction::SetSplitRatio { path, ratio }); - } - DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - } => { - if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( - terminal_runtimes, - *pane_id, - mouse.row, - *grab_row_offset, - ) { - self.set_pane_scroll_offset( - terminal_runtimes, - *pane_id, - offset_from_bottom, - ); - } - } - DragTarget::SidebarDivider => { - self.set_manual_sidebar_width(mouse.column); - } - DragTarget::SidebarSectionDivider => { - self.set_sidebar_section_split(mouse.row); - } - DragTarget::ReleaseNotesScrollbar { .. } - | DragTarget::ProductAnnouncementScrollbar { .. } - | DragTarget::KeybindHelpScrollbar { .. } => {} - } - } - } - - MouseEventKind::Up(MouseButton::Left) => { - // Mouse-up either finishes a drag selection or releases after a - // double-click word selection; the latter is already finalized. - if let Some(selection) = self.selection.as_ref() { - let was_click = selection.was_just_click(); - let was_finalized = selection.is_finalized(); - - self.clear_chrome_press(source_id); - self.drag = None; - self.selection_autoscroll = None; - if was_click { - self.selection = None; - } else if was_finalized { - // Double-click already finalized this word selection. - } else if self.copy_on_select { - self.copy_selection(terminal_runtimes); - } else if let Some(selection) = self.selection.as_mut() { - selection.finish(); - } - return None; - } - - let foreign_chrome_drag = self.chrome_drag_owned_by_other(source_id); - if (self.drag.is_none() || foreign_chrome_drag) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_press = self.workspace_presses.remove(&source_id); - let tab_press = self.tab_presses.remove(&source_id); - if foreign_chrome_drag { - return self.chrome_press_action(workspace_press, tab_press); - } - - match self.drag.take() { - Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target: Some(drop_target), - .. - }, - }) => { - if let Some(params) = - self.workspace_move_block_params(source_ws_idx, drop_target) - { - if self - .workspaces - .get(source_ws_idx) - .is_some_and(|workspace| workspace.worktree_space().is_some()) - { - return Some(MouseAction::MoveWorkspaceBlock { params }); - } - let insert_idx = params - .before_workspace_id - .as_ref() - .and_then(|id| { - self.workspaces - .iter() - .position(|workspace| workspace.id == *id) - }) - .unwrap_or(self.workspaces.len()); - return Some(MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - }); - } - } - Some(DragState { - target: - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx: Some(insert_idx), - .. - }, - }) => { - if self.active == Some(ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::MoveTab { - ws_idx, - source_tab_idx, - insert_idx, - }); - } - } - Some(_) => {} - None => return self.chrome_press_action(workspace_press, tab_press), - } - } - - MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) - if !in_sidebar => - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.on_tab_bar(mouse.column, mouse.row) => - { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let prev = if ws.active_tab == 0 { - ws.tabs.len() - 1 - } else { - ws.active_tab - 1 - }; - return Some(MouseAction::FocusTab { tab_idx: prev }); - } - } - } - MouseEventKind::ScrollDown => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let next = (ws.active_tab + 1) % ws.tabs.len(); - return Some(MouseAction::FocusTab { tab_idx: next }); - } - } - } - _ => {} - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { - self.selection = None; - self.selection_autoscroll = None; - self.handle_terminal_wheel(terminal_runtimes, mouse); - } - - MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight - if self.mode == Mode::Terminal && !in_sidebar => - { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(-1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(-1); - } else { - self.move_selected_workspace_by_visible_delta(-1); - } - } - MouseEventKind::ScrollDown if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(1); - } else { - self.move_selected_workspace_by_visible_delta(1); - } - } - - MouseEventKind::Moved if self.mode == Mode::ContextMenu => { - let hovered = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = &mut self.context_menu { - menu.list.hover(hovered); - } - } - - MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { - self.clear_chrome_press(source_id); - if self - .workspace_list_scrollbar_target_at(mouse.column, mouse.row) - .is_some() - { - return None; - } - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.selected = idx; - let kind = self - .workspaces - .get(idx) - .and_then(|ws| { - let group_state = crate::ui::workspace_parent_group_state(self, idx); - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - let is_linked_worktree = ws.worktree_space().map_or_else( - || { - git_space - .as_ref() - .is_some_and(|space| space.is_linked_worktree) - }, - |space| space.is_linked_worktree, - ); - let show_git_menu = ws.worktree_space().is_some() - || git_space - .as_ref() - .is_some_and(|space| !space.is_linked_worktree); - show_git_menu.then_some(ContextMenuKind::GitWorkspace { - ws_idx: idx, - is_linked_worktree, - has_worktree_children: group_state.is_some(), - collapsed: group_state - .as_ref() - .is_some_and(|(_, collapsed)| *collapsed), - }) - }) - .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx }); - self.context_menu = Some(ContextMenuState { - kind, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) - if !self.mode_bar_covers_tab_row(mouse.column, mouse.row) - && self.tab_at(mouse.column, mouse.row).is_some() => - { - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Tab { ws_idx, tab_idx }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let ws_idx = self.active?; - let tab_idx = self - .workspaces - .get(ws_idx) - .map(|ws| ws.active_tab_index())?; - let previous_focused_pane_id = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()); - let source_pane_id = - previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); - let pane_state = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)); - let has_manual_label = pane_state - .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.as_ref()) - .is_some(); - let right_click_passthrough = - pane_state.is_some_and(|pane| pane.right_click_passthrough); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id: info.id, - source_pane_id, - has_manual_label, - right_click_passthrough, - }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - _ => {} - } - - None - } - - fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> MobileMouseResult { - if self.mode == Mode::Navigate { - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); - return MobileMouseResult::Consumed; - } - MouseEventKind::ScrollDown => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); - return MobileMouseResult::Consumed; - } - MouseEventKind::Down(MouseButton::Left) => {} - _ => return MobileMouseResult::Consumed, - } - } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return MobileMouseResult::Ignored; - } - - if self.mode != Mode::Navigate { - if !matches!(self.mode, Mode::Terminal | Mode::Resize) { - return MobileMouseResult::Ignored; - } - if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { - self.mobile_switcher_scroll = 0; - self.mode = Mode::Navigate; - return MobileMouseResult::Consumed; - } - return MobileMouseResult::Ignored; - } - - let areas = crate::ui::mobile_switcher_areas(self); - if rect_contains(areas.close, mouse.column, mouse.row) { - self.mode = Mode::Terminal; - return MobileMouseResult::Consumed; - } - - match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { - Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { - return MobileMouseResult::Action(MouseAction::NewWorkspace); - } - Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusWorkspace { ws_idx }); - } - Some(crate::ui::MobileSwitcherTarget::NewTab) => { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - } - Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusTab { tab_idx }); - } - Some(crate::ui::MobileSwitcherTarget::Agent { - ws_idx, - tab_idx: _, - pane_id, - }) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusPane { ws_idx, pane_id }); - } - Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { - let actions = global_menu_actions(self); - if let Some(action) = actions.get(action_idx).copied() { - apply_global_menu_action(self, action); - } - } - None => {} - } - - MobileMouseResult::Consumed - } - - fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { - let max_scroll = crate::ui::mobile_switcher_max_scroll(self); - apply_scroll( - &mut self.mobile_switcher_scroll, - delta.saturating_mul(2), - max_scroll, - ); - } - - pub(super) fn screen_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - let terminal = self.view.terminal_area; - let x = sidebar.x.min(terminal.x); - let y = sidebar.y.min(terminal.y); - let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); - let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) - } - - pub(crate) fn context_menu_rect(&self) -> Option { - let menu = self.context_menu.as_ref()?; - let screen = self.screen_rect(); - let max_item_w = menu - .items() - .iter() - .map(|item| item.len() as u16) - .max() - .unwrap_or(0); - let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); - let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); - let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); - let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); - Some(Rect::new(x, y, menu_w, menu_h)) - } - - pub(crate) fn confirm_close_rect(&self) -> Rect { - crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() - } - - fn context_menu_item_at(&self, col: u16, row: u16) -> Option { - let menu_rect = self.context_menu_rect()?; - let inner_x = menu_rect.x + 1; - let inner_y = menu_rect.y + 1; - let inner_w = menu_rect.width.saturating_sub(2); - let inner_h = menu_rect.height.saturating_sub(2); - let item_count = self - .context_menu - .as_ref() - .map(|menu| menu.items().len() as u16) - .unwrap_or(0); - if col >= inner_x - && col < inner_x + inner_w - && row >= inner_y - && row < inner_y + inner_h.min(item_count) - { - Some((row - inner_y) as usize) - } else { - None - } - } - - pub(super) fn tab_at(&self, col: u16, row: u16) -> Option { - self.view - .tab_hit_areas - .iter() - .enumerate() - .find_map(|(idx, area)| { - (area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width) - .then_some(idx) - }) - } - - fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool { - self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && matches!( - self.mode, - Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize - ) - && self.on_tab_bar(col, row) - } - - pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_bar_rect; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_left_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_right_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option { - if !self.on_tab_bar(col, row) { - return None; - } - - let visible_tabs: Vec<_> = self - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0) - .collect(); - let (first_idx, first_rect) = *visible_tabs.first()?; - let (last_idx, last_rect) = *visible_tabs.last()?; - - if self.on_tab_scroll_left_button(col, row) { - return Some(0); - } - if self.on_tab_scroll_right_button(col, row) { - return self - .active - .and_then(|idx| self.workspaces.get(idx)) - .map(|ws| ws.tabs.len()); - } - - let left_edge = if first_idx == 0 { - first_rect.x - } else { - self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width - }; - let right_edge = if self - .active - .and_then(|idx| self.workspaces.get(idx)) - .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) - { - last_rect.x + last_rect.width - } else { - self.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }; - - if col <= left_edge { - return Some(first_idx); - } - if col >= right_edge { - return Some(last_idx + 1); - } - - for (idx, rect) in visible_tabs { - let midpoint = rect.x + rect.width / 2; - if col < midpoint { - return Some(idx); - } - if col < rect.x + rect.width { - return Some(idx + 1); - } - } - - Some(last_idx + 1) - } - - pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool { - let area = self.view.new_tab_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { - self.view.split_borders.iter().find(|b| match b.direction { - Direction::Horizontal if self.pane_borders && !self.pane_gaps => { - col == b.pos && row >= b.area.y && row < b.area.y + b.area.height - } - Direction::Horizontal if self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && col >= b.pos.saturating_sub(1) - && col <= b.pos - } - Direction::Horizontal if !self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && b.pos.checked_sub(1).is_some_and(|gap_col| { - col == gap_col && self.pane_frame_at(col, row).is_none() - }) - } - Direction::Vertical if self.pane_borders && !self.pane_gaps => { - row == b.pos && col >= b.area.x && col < b.area.x + b.area.width - } - Direction::Vertical if self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && row >= b.pos.saturating_sub(1) - && row <= b.pos - } - Direction::Vertical if !self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && b.pos.checked_sub(1).is_some_and(|gap_row| { - row == gap_row && self.pane_frame_at(col, row).is_none() - }) - } - _ => false, - }) - } - - pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.inner_rect.x - && col < p.inner_rect.x + p.inner_rect.width - && row >= p.inner_rect.y - && row < p.inner_rect.y + p.inner_rect.height - }) - } - - pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.pane_at(col, row) - .or_else(|| self.pane_frame_at(col, row)) - } - - fn chrome_press_pending(&self, source_id: crate::app::InputSourceId) -> bool { - self.tab_presses.contains_key(&source_id) || self.workspace_presses.contains_key(&source_id) - } - - fn chrome_drag_owned_by_other(&self, source_id: crate::app::InputSourceId) -> bool { - self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id != source_id - ) - }) - } - - fn chrome_press_action( - &mut self, - workspace_press: Option, - tab_press: Option, - ) -> Option { - if let Some(press) = workspace_press { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { - ws_idx: press.ws_idx, - }); - } - if let Some(press) = tab_press { - if self.active == Some(press.ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusTab { - tab_idx: press.tab_idx, - }); - } - } - None - } - - pub(crate) fn clear_chrome_gesture(&mut self, source_id: crate::app::InputSourceId) { - if self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id == source_id - ) - }) { - self.drag = None; - } - self.clear_chrome_press(source_id); - } - - fn clear_chrome_press(&mut self, source_id: crate::app::InputSourceId) { - self.tab_presses.remove(&source_id); - self.workspace_presses.remove(&source_id); - } - - fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option { - let ws_idx = self.active?; - (self - .workspaces - .get(ws_idx) - .and_then(|workspace| workspace.focused_pane_id()) - != Some(pane_id)) - .then_some(MouseAction::FocusPane { ws_idx, pane_id }) - } - - pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|info| info.id == pane_id) - } - - pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.rect.x - && col < p.rect.x + p.rect.width - && row >= p.rect.y - && row < p.rect.y + p.rect.height - }) - } - - pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - let _ = pane_id; - } - - fn clickable_toast_at(&self, col: u16, row: u16) -> bool { - self.toast - .as_ref() - .is_some_and(|toast| toast.target.is_some()) - && rect_contains(self.view.toast_hit_area, col, row) - } - - #[cfg(test)] - pub(crate) fn focus_toast_target(&mut self) { - let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { - return; - }; - let Some(ws_idx) = self - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { - return; - }; - - self.focus_pane_in_workspace(ws_idx, target.pane_id); - self.toast = None; - self.settle_terminal_mode_after_focus(); - } - - pub(crate) fn scroll_pane_up( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_up(lines); - } - } - } - - pub(crate) fn scroll_pane_down( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_down(lines); - } - } - } - - pub(crate) fn pane_scroll_metrics( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option { - self.active - .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - } - - fn handle_right_click_passthrough( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - in_sidebar: bool, - ) -> bool { - if let Some(gesture) = self.right_click_passthrough.clone() { - match mouse.kind { - MouseEventKind::Drag(MouseButton::Right) - | MouseEventKind::Up(MouseButton::Right) => { - let forwarded_mouse = - self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers); - let _ = self.forward_pane_mouse_button( - terminal_runtimes, - &gesture.pane_info, - forwarded_mouse, - ); - if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) { - self.right_click_passthrough = None; - } - return true; - } - _ => { - self.right_click_passthrough = None; - } - } - } - - if self.mode != Mode::Terminal - || in_sidebar - || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) - { - return false; - } - - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let configured_modifiers = self - .right_click_passthrough_modifiers - .filter(|modifiers| mouse.modifiers == *modifiers); - let pane_passthrough = mouse.modifiers.is_empty() - && self.active.is_some_and(|ws_idx| { - self.workspaces - .get(ws_idx) - .and_then(|workspace| workspace.pane_state(info.id)) - .is_some_and(|pane| pane.right_click_passthrough) - }); - let Some(modifiers) = configured_modifiers - .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) - else { - return false; - }; - - self.focus_pane(info.id); - let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers); - if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) { - return false; - } - - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - self.drag = None; - self.context_menu = None; - self.right_click_passthrough = Some(RightClickPassthroughGesture { - pane_info: info, - modifiers, - }); - true - } - - fn strip_right_click_passthrough_modifiers( - &self, - mouse: MouseEvent, - modifiers: crossterm::event::KeyModifiers, - ) -> MouseEvent { - MouseEvent { - modifiers: mouse.modifiers.difference(modifiers), - ..mouse - } - } - - pub(super) fn handle_terminal_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - let lines_per_notch = self.mouse_scroll_lines; - - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { - return; - } - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), - MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), - _ => {} - } - } - } - } - - fn pane_mouse_position( - &self, - runtime: &crate::terminal::TerminalRuntime, - inner: Rect, - mouse: MouseEvent, - ) -> Option { - let column = mouse.column.saturating_sub(inner.x); - let row = mouse.row.saturating_sub(inner.y); - let cell = crate::input::mouse::Position::Cell { column, row }; - let Some(host) = self.host_mouse_pixels else { - return Some(cell); - }; - let wants_pixels = runtime.sgr_pixel_mouse_enabled(); - if !wants_pixels { - return Some(cell); - } - let Some((width_px, height_px)) = runtime.pixel_size() else { - return Some(cell); - }; - Some( - host.pane_position(inner, width_px, height_px) - .unwrap_or(cell), - ) - } - - pub(super) fn forward_pane_mouse_button( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) else { - return false; - }; - rt.scroll_reset(); - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); - } - true - } - - pub(super) fn forward_pane_mouse_motion( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers) else { - return false; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event"); - } - true - } - - fn forward_pane_reported_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { - return false; - } - rt.scroll_reset(); - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) else { - warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); - } - true - } - - pub(super) fn forward_pane_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - match rt.wheel_routing() { - Some(crate::pane::WheelRouting::HostScroll) | None => false, - Some(crate::pane::WheelRouting::MouseReport) => { - self.forward_pane_reported_wheel(terminal_runtimes, info, mouse) - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.scroll_reset(); - let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); - } - true - } - } - } - - pub(super) fn set_pane_scroll_offset( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - offset_from_bottom: usize, - ) { - for ws_idx in 0..self.workspaces.len() { - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - continue; - }; - rt.set_scroll_offset_from_bottom(offset_from_bottom); - return; - } - } - - pub(super) fn scrollbar_target_at( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws_idx = self.active?; - let info = self.view.pane_infos.iter().find(|info| { - crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { - col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height - }) - })?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - let track = crate::ui::pane_scrollbar_rect(info)?; - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) - } else { - Some(( - info.id, - ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }, - )) - } - } - - pub(super) fn scrollbar_offset_for_pane_row( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - row: u16, - grab_row_offset: u16, - ) -> Option { - let ws_idx = self.active?; - let info = self - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id)?; - let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } -} - -#[cfg(test)] -pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { - if input_state.mouse_protocol_mode.reporting_enabled() { - WheelRouting::MouseReport - } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { - WheelRouting::AlternateScroll - } else { - WheelRouting::HostScroll - } -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) { - if delta.is_negative() { - *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize); - } else { - *scroll = scroll.saturating_add(delta as usize).min(max_scroll); - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::{Direction, Rect}; - - use super::super::{ - app_for_mouse_test, capture_snapshot, mouse, numbered_lines_bytes, root_layout_ratio, - }; - use super::*; - use crate::app::input::modal::handle_context_menu_key; - use crate::{ - app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn tab_click_survives_stray_drag_report_off_the_tab_bar() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = area.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn workspace_click_survives_stray_drag_report_off_the_workspace_list() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("second")]; - app.state.active = Some(1); - app.state.selected = 1; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_workspace = app.state.view.workspace_card_areas[0]; - let press_col = first_workspace.rect.x + 1; - let press_row = first_workspace.rect.y; - let stray_row = area.height - 1; - assert!(app.state.workspace_drop_target_at_row(stray_row).is_none()); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - press_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.active, Some(0)); - } - - #[test] - fn concurrent_input_sources_keep_their_tab_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Up(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - } - - #[test] - fn concurrent_input_sources_keep_their_workspace_clicks() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("first"), - Workspace::test_new("second"), - Workspace::test_new("third"), - ]; - app.state.active = Some(2); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first = app.state.view.workspace_card_areas[0].rect; - let second = app.state.view.workspace_card_areas[1].rect; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first.x + 1, - first.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), first.x + 1, first.y), - ); - assert_eq!(app.state.active, Some(0)); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn tab_click_completes_while_other_source_reorders() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - let last_tab = app.state.view.tab_hit_areas[2]; - let drop_col = last_tab.x + last_tab.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { source_id: 41, .. }) - )); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, first_tab.y), - ); - assert!(app.state.drag.is_none()); - } - - #[test] - fn releasing_input_source_clears_only_its_pending_tab_click() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.clear_input_source(41); - - assert!(!app.state.tab_presses.contains_key(&41)); - assert!(app.state.tab_presses.contains_key(&42)); - } - - #[tokio::test] - async fn other_input_source_pane_gesture_is_not_swallowed_by_chrome_press() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app.state.view.pane_infos[0].clone(); - let pane_id = info.id; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1000h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let pane_col = info.inner_rect.x; - let pane_row = info.inner_rect.y; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Down(MouseButton::Left), pane_col, pane_row), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), pane_col, pane_row), - ); - - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - } - - #[test] - fn other_input_source_cannot_release_tab_reorder() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("second")); - ws.test_add_tab(Some("third")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let target = app.state.view.tab_hit_areas[2]; - let drop_col = target.x + target.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Drag(MouseButton::Left), drop_col, source.y), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_some()); - assert_eq!(app.state.workspaces[0].tabs[0].custom_name, None); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_none()); - assert_eq!(app.state.workspaces[0].tabs[2].custom_name.as_deref(), None); - } - - #[tokio::test] - async fn tab_click_survives_stray_drag_report_into_a_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app - .state - .view - .pane_infos - .first() - .cloned() - .expect("visible pane"); - app.state.insert_test_runtime( - info.id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width.max(1), - info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = info.inner_rect.bottom().saturating_sub(1); - assert!( - app.state.pane_mouse_target(press_col, stray_row).is_some(), - "stray coordinates must land on the pane for this to test anything" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) { - workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[tokio::test] - async fn terminal_wheel_uses_configured_mouse_scroll_lines() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.mouse_scroll_lines = 7; - - app.handle_mouse(mouse( - MouseEventKind::ScrollUp, - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!(metrics.offset_from_bottom, 7); - } - - #[tokio::test] - async fn mouse_dispatcher_forwards_horizontal_wheel_to_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1000h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - assert!( - app.state.mouse_capture, - "reproduction must use the default Herdr mouse dispatcher" - ); - - let outer_column = info.inner_rect.x + 2; - let outer_row = info.inner_rect.y + 3; - for (button, expected_kind, ingress) in [ - (66, MouseEventKind::ScrollLeft, "monolithic"), - (67, MouseEventKind::ScrollRight, "headless"), - ] { - let input = format!("\x1b[<{button};{};{}M", outer_column + 1, outer_row + 1); - let mut events = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()); - let event = events - .pop() - .expect("horizontal SGR wheel input should parse"); - let crate::raw_input::RawInputEvent::Mouse(mouse) = &event else { - panic!("expected parsed mouse event"); - }; - assert!(events.is_empty(), "expected one parsed mouse event"); - assert_eq!(mouse.kind, expected_kind); - - if ingress == "monolithic" { - assert!(app.handle_raw_input_event(event).await); - } else { - app.route_client_events(vec![event], false); - } - - assert_eq!( - input_rx - .try_recv() - .expect("horizontal wheel should reach pane"), - Bytes::from(format!("\x1b[<{button};3;4M")) - ); - } - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn horizontal_wheel_stays_inert_for_non_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"", - 1, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let input = format!( - "\x1b[<66;{};{}M", - info.inner_rect.x + 3, - info.inner_rect.y + 4 - ); - let event = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()) - .pop() - .expect("horizontal SGR wheel input should parse"); - - assert!(app.handle_raw_input_event(event).await); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_passthrough_is_isolated() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let passthrough_pane = ws.tabs[0].root_pane; - let default_pane = ws.test_split(Direction::Horizontal); - ws.pane_state_mut(passthrough_pane) - .unwrap() - .right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); - let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); - let (passthrough_runtime, mut passthrough_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - passthrough_info.inner_rect.width, - passthrough_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - let (default_runtime, mut default_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - default_info.inner_rect.width, - default_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state - .insert_test_runtime(passthrough_pane, passthrough_runtime); - app.state.insert_test_runtime(default_pane, default_runtime); - - let col = passthrough_info.inner_rect.x + 2; - let row = passthrough_info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert_eq!( - passthrough_input.try_recv().unwrap(), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - default_info.inner_rect.x + 2, - default_info.inner_rect.y + 3, - )); - - assert!(default_input.try_recv().is_err()); - assert!(matches!( - app.state.context_menu.as_ref().map(|menu| &menu.kind), - Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane - )); - } - - #[tokio::test] - async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.pane_info_by_id(pane_id).unwrap().clone(); - app.state.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - b"", - ), - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - } - - #[tokio::test] - async fn configured_right_click_passthrough_forwards_gesture_outside_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Drag(MouseButton::Right), 0, 0) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Up(MouseButton::Right), 0, 0) - }); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert!(app.state.right_click_passthrough.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse down"), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse drag"), - Bytes::from_static(b"\x1b[<34;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse up"), - Bytes::from_static(b"\x1b[<2;1;1m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn captured_left_press_focuses_target_before_forwarding() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target)); - assert_eq!( - input_rx.try_recv().expect("forwarded captured left press"), - Bytes::from_static(b"\x1b[<0;2;2M") - ); - } - - #[tokio::test] - async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.state.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.state.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ordinary_cell_mouse_downgrades_pixel_mode_to_cell_coordinates() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap() - .resize(info.inner_rect.height, info.inner_rect.width, 10, 20); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn dedicated_client_pixel_mouse_preserves_subcell_position() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let inner = app.state.view.pane_infos[0].inner_rect; - let geometry = crate::input::mouse::HostGeometry::new(106, 20, 1_060, 400).unwrap(); - let x = u32::from(inner.x + 2) * 10 + 8; - let y = u32::from(inner.y + 3) * 20 + 9; - let report = format!("\x1b[<35;{x};{y}M"); - app.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!(runtime.pixel_size(), None); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Cell { column: 2, row: 3 }) - ); - runtime.resize(inner.height, inner.width, 10, 20); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!( - runtime.pixel_size(), - Some((u32::from(inner.width) * 10, u32::from(inner.height) * 20)) - ); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Pixels { x: 28, y: 69 }) - ); - app.state.host_mouse_pixels = None; - - assert!(app.route_client_pixel_mouse(7, report.as_bytes(), geometry)); - assert_eq!( - input_rx.try_recv().expect("forwarded exact mouse motion"), - Bytes::from_static(b"\x1b[<35;28;69M") - ); - assert!(input_rx.try_recv().is_err()); - assert!(app.state.host_mouse_pixels.is_none()); - } - - #[tokio::test] - async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.right_click_passthrough_modifiers = None; - - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .expect("target pane info") - .clone(); - let source_rect_before = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .expect("source pane info") - .rect; - let target_rect_before = target_info.rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - let swap_idx = menu - .items() - .iter() - .position(|item| *item == "Swap with focused pane") - .expect("swap item"); - menu.list.highlighted = swap_idx; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .unwrap() - .rect, - target_rect_before - ); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .unwrap() - .rect, - source_rect_before - ); - } - - #[tokio::test] - async fn normal_right_click_keeps_focus_and_exposes_swap_for_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - target_info.inner_rect.width, - target_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert!(input_rx.try_recv().is_err()); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - assert!(menu.items().contains(&"Swap with focused pane")); - } - - #[tokio::test] - async fn right_click_passthrough_requires_exact_modifier_match() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn right_click_passthrough_does_not_forward_pane_frame_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let other_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - app.state.insert_test_runtime( - other_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""), - ); - - assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none()); - assert!(app - .state - .pane_mouse_target(info.rect.x, info.rect.y) - .is_some()); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.rect.x, - info.rect.y, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState { - crate::app::state::WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - } - } - - #[test] - fn hovering_context_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 0 }, - x: 2, - y: 2, - list: MenuListState::new(0), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); - } - - #[test] - fn clicking_agent_toast_focuses_target_pane() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let mut background = Workspace::test_new("background"); - let first_pane = background.tabs[0].root_pane; - let target_pane = background.test_split(Direction::Horizontal); - background.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![active, background]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - app.state.toast_config.delay_seconds = 0; - let target_terminal_id = app.state.workspaces[1] - .panes - .get(&target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .state = AgentState::Working; - - app.state - .handle_app_event(crate::events::AppEvent::StateChanged { - pane_id: target_pane, - agent: Some(Agent::Pi), - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), - }); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(app.state.toast.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(app.state.workspaces[0].tabs[0].root_pane) - ); - } - - #[test] - fn toast_click_does_not_steal_mouse_from_settings_overlay() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let background = Workspace::test_new("background"); - let target_pane = background.tabs[0].root_pane; - let workspace_id = background.id.clone(); - - app.state.workspaces = vec![active, background]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "background · 2".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id, - pane_id: target_pane, - }), - }); - app.state.mode = Mode::Settings; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(app.state.toast.is_some()); - } - - #[test] - fn clicking_confirm_close_accepts_workspace_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.begin_workspace_close_confirmation(1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_rename_save_submits_workspace_rename_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - let inner = app.state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - save.x, - save.y, - )); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn clicking_open_worktree_row_selects_and_requests_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - inner.x + 1, - inner.y + 5, - )); - - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - assert!(app.state.request_submit_worktree_open); - } - - #[test] - fn clicking_open_worktree_buttons_requests_open_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (open, _) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - open.x, - open.y, - )); - - assert!(app.state.worktree_open.is_some()); - assert!(app.state.request_submit_worktree_open); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_open.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn scrolling_open_worktree_picker_moves_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0); - } - - #[test] - fn clicking_remove_worktree_buttons_requests_remove_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - remove.x, - remove.y, - )); - - assert!(app.state.worktree_remove.is_some()); - assert!(app.state.request_submit_worktree_remove); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_remove.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x + 1, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - } - - #[test] - fn clicking_context_menu_close_routes_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[cfg(unix)] - #[tokio::test] - async fn keyboard_context_menu_split_keeps_new_runtime() { - let mut app = app_for_mouse_test(); - app.state.default_shell = "/usr/bin/true".into(); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let runtime_count = app.terminal_runtimes.len(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[test] - fn dragging_pane_split_updates_captured_layout_ratio() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_right_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_none()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_bottom_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Vertical); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let col = border.area.x.saturating_add(1); - - assert!(app - .state - .find_border_at(col, border.pos.saturating_sub(1)) - .is_none()); - assert!(app.state.find_border_at(col, border.pos).is_some()); - assert!(app - .state - .find_border_at(col, border.pos.saturating_add(1)) - .is_none()); - } - - #[test] - fn borderless_no_gap_split_has_no_mouse_hitbox_over_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn bordered_pane_gaps_keep_both_split_borders_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_some()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn borderless_pane_gap_is_not_a_pane_but_remains_split_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_at(gap_col, row).is_none()); - assert!(app.state.find_border_at(gap_col, row).is_some()); - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_one_cell() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 2, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let candidate_gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_frame_at(candidate_gap_col, row).is_some()); - assert!(app.state.find_border_at(candidate_gap_col, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_zero_width() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].tabs[0] - .layout - .set_ratio_at(&[], 0.1); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 1, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert_eq!(border.pos, 0); - assert!(app.state.find_border_at(0, row).is_none()); - } - - #[test] - fn selecting_from_right_pane_first_content_column_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Horizontal); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[test] - fn selecting_from_bottom_pane_first_content_row_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[tokio::test] - async fn dragging_vertical_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Vertical); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Vertical) - .expect("vertical split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_col = border.area.x.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - drag_col, - border.pos, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drag_col, - border.pos.saturating_add(4), - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[tokio::test] - async fn dragging_horizontal_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Horizontal) - .expect("horizontal split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn wheel_routing_prefers_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); - } - - #[test] - fn wheel_over_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 2); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(1), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn bottom_mode_bar_consumes_hidden_tab_mouse_actions() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - let new_tab = app.state.view.new_tab_hit_area; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab.x, - new_tab.y, - )); - - app.state.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.workspaces[0].tabs.len(), 2); - assert!(app.state.context_menu.is_none()); - assert!(app.state.tab_presses.is_empty()); - assert!(app.state.drag.is_none()); - } - - #[test] - fn right_click_inactive_tab_opens_menu_without_switching_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - let menu = app.state.context_menu.as_ref().expect("tab context menu"); - assert_eq!( - menu.kind, - ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 1 - } - ); - assert_eq!(app.state.mode, Mode::ContextMenu); - } - - #[test] - fn clicking_tab_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - let menu = app - .state - .context_menu_rect() - .expect("tab context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "one"); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::TabClosed) })); - } - - #[test] - fn clicking_pane_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(second_pane); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let first_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - first_info.inner_rect.x + 1, - first_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::PaneClosed) - })); - } - - #[test] - fn clicking_pane_context_menu_close_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_for_mouse_test(); - let mut parent = Workspace::test_new("main"); - let pane_id = parent.tabs[0].root_pane; - mark_worktree_space_member(&mut parent, 0, "repo-key"); - let mut child = Workspace::test_new("issue"); - mark_worktree_space_member(&mut child, 1, "repo-key"); - app.state.workspaces = vec![parent, child]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let pane_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - pane_info.inner_rect.x + 1, - pane_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn wheel_over_overflowing_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.tabs[0].set_custom_name("very-long-one".into()); - ws.test_add_tab(Some("very-long-two")); - ws.test_add_tab(Some("very-long-three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - assert!(app.state.view.tab_scroll_right_hit_area.width > 0); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn wheel_outside_tab_bar_does_not_switch_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let terminal = app.state.view.terminal_area; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - terminal.x + 1, - terminal.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.view.layout, ViewLayout::Mobile); - - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_workspace_panel_scroll_reaches_extra_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = (0..12) - .map(|idx| Workspace::test_new(&format!("ws-{idx}"))) - .collect(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.mobile_switcher_scroll, 2); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_global_scroll_reaches_tabs_and_switches_tab() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - ws.test_add_tab(Some("four")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - assert_eq!(app.state.mobile_switcher_scroll, 4); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn mobile_switcher_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 1, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[test] - fn desktop_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[tokio::test] - async fn desktop_new_workspace_creates_immediately_by_default() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.pending_workspace_create_cwd.is_none()); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - } - - #[test] - fn mobile_switcher_new_tab_opens_dialog_when_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - - assert_eq!(app.state.mode, Mode::RenameTab); - assert!(app.state.creating_new_tab); - } - - #[test] - fn mobile_switcher_new_tab_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn desktop_new_tab_button_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_tab_area = app.state.view.new_tab_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab_area.x + 1, - new_tab_area.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn mobile_switcher_swallows_non_left_mouse_events() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn mobile_switch_button_does_not_bypass_rename_modal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.creating_new_tab = true; - app.state.name_input = "new tab".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(!app.state.request_new_tab); - } - - #[test] - fn mobile_switcher_close_returns_to_terminal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let close = crate::ui::mobile_switcher_areas(&app.state).close; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x + 1, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); - } - - #[test] - fn wheel_routing_falls_back_to_host_scrollback() { - let input_state = crate::pane::InputState { - alternate_screen: false, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); - } -} diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs deleted file mode 100644 index 517e267d..00000000 --- a/src/app/input/navigate.rs +++ /dev/null @@ -1,3898 +0,0 @@ -use std::{ - fs, io, - io::Write, - process::Stdio, - time::{SystemTime, UNIX_EPOCH}, -}; - -use bytes::Bytes; -use crossterm::event::KeyCode; -#[cfg(test)] -use crossterm::event::KeyEvent; -use ratatui::layout::Direction; - -use crate::{ - app::{ - state::{AppState, Mode}, - App, - }, - input::TerminalKey, - layout::NavDirection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -pub(crate) fn terminal_direct_navigation_action( - state: &AppState, - key: TerminalKey, -) -> Option { - action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_non_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option { - non_indexed_action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option { - indexed_navigation_action(state, key, BindingDispatch::Direct) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActionContext { - Direct, - Prefix, - Navigate, -} - -impl App { - fn cancel_copy_mode_if_active(&mut self) { - if self.state.copy_mode.is_some() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - } - - pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if matches!(key.code, KeyCode::Modifier(_)) { - return; - } - - if self.state.is_prefix_key(&raw_key) { - if self.state.copy_mode_pane_is_focused() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - if !self.pass_through_key_to_focused_pane(raw_key) { - leave_command_mode(&mut self.state); - } - return; - } - - if key.code == KeyCode::Esc { - leave_command_mode(&mut self.state); - return; - } - - match prefix_binding_for_key(&self.state, &raw_key) { - Some(PrefixBindingMatch::Action(action)) => self.execute_prefix_key_action(action), - Some(PrefixBindingMatch::Command(binding)) => { - self.cancel_copy_mode_if_active(); - self.launch_custom_command(binding, ActionContext::Prefix); - } - None => leave_command_mode(&mut self.state), - } - } - - fn execute_prefix_key_action(&mut self, action: NavigateAction) { - if action == NavigateAction::EditScrollback { - let previous_mode = self.state.mode; - self.cancel_copy_mode_if_active(); - self.launch_focused_scrollback_editor(); - finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); - } else if action == NavigateAction::CopyMode { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } else if copy_mode_survives_prefix_action(action) { - self.execute_tui_navigate_action(action, ActionContext::Prefix); - if self.state.copy_mode.is_some() { - self.state.sync_copy_mode_with_focus(); - } - } else { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } - self.selection_autoscroll_deadline = None; - } - - pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { - leave_navigate_mode(&mut self.state); - return; - } - - if self - .state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(-1); - return; - } - if self - .state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(1); - return; - } - - if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { - if action == NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - } - self.selection_autoscroll_deadline = None; - return; - } - - if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { - self.launch_custom_command(binding, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - self.selection_autoscroll_deadline = None; - } - } - - pub(super) fn execute_tui_navigate_action( - &mut self, - action: NavigateAction, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - match action { - NavigateAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.key.workspace.create"); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - super::modal::open_rename_workspace( - &mut self.state, - &self.terminal_runtimes, - ws_idx, - ); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.selected = ws_idx; - if self.state.confirm_close { - super::modal::open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = self.state.workspace_at_visible_position(idx) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::SwitchTab(idx) => { - if self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()) - { - self.focus_tab_idx_via_api(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::FocusAgent(idx) => { - if let Some((ws_idx, pane_id)) = self.agent_entry_target(idx) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::WorkspacePicker => { - self.state.mobile_switcher_scroll = 0; - self.state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(-1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::PreviousAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(false) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(true) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NewTab => { - if self.state.active.is_some() { - if self.state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(&mut self.state); - } else { - self.runtime_tab_create( - "tui.key.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::RenameTab => { - super::modal::open_rename_active_tab(&mut self.state, false) - } - NavigateAction::PreviousTab => { - if let Some(tab_idx) = self.relative_tab(-1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextTab => { - if let Some(tab_idx) = self.relative_tab(1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::MoveTabPrevious => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::MoveTabNext => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::CloseTab => { - if !self.close_active_tab_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(&mut self.state, pane_id); - } - } - NavigateAction::FocusPaneLeft => { - self.focus_pane_direction_in_context(NavDirection::Left, context) - } - NavigateAction::FocusPaneDown => { - self.focus_pane_direction_in_context(NavDirection::Down, context) - } - NavigateAction::FocusPaneUp => { - self.focus_pane_direction_in_context(NavDirection::Up, context) - } - NavigateAction::FocusPaneRight => { - self.focus_pane_direction_in_context(NavDirection::Right, context) - } - NavigateAction::SwapPaneLeft => { - self.swap_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneDown => { - self.swap_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneUp => { - self.swap_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneRight => { - self.swap_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitVertical => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitHorizontal => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ClosePane => { - if !self.close_focused_pane_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => self.state.enter_copy_mode(&self.terminal_runtimes), - NavigateAction::Zoom => { - self.zoom_focused_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - self.resize_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneDown => { - self.resize_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneUp => { - self.resize_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneRight => { - self.resize_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ToggleSidebar => { - self.state.sidebar_collapsed = !self.state.sidebar_collapsed; - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePaneNext => { - self.cycle_pane_via_api(false); - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePanePrevious => { - self.cycle_pane_via_api(true); - leave_navigate_mode(&mut self.state); - } - NavigateAction::LastPane => { - self.last_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), - NavigateAction::Settings => super::settings::open_settings(&mut self.state), - NavigateAction::ReloadConfig => { - self.runtime_server_reload_config("tui.server.reload_config"); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNotificationTarget => { - self.focus_toast_target_via_api(); - if self.state.mode == Mode::Navigate { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(&mut self.state); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNavigator => { - self.state.open_navigator_from(&self.terminal_runtimes) - } - } - - finish_action_context(&mut self.state, context, previous_mode); - } - - pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_focus("tui.workspace.focus", workspace_id); - } - - pub(crate) fn close_workspace_idx_with_group_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_close_group("tui.workspace.close", workspace_id); - } - - pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { - let workspace_id = self.public_workspace_id(source_ws_idx); - self.runtime_workspace_move( - "tui.workspace.move", - crate::api::schema::WorkspaceMoveParams { - workspace_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn move_workspace_block_via_api( - &mut self, - params: crate::api::schema::WorkspaceMoveBlockParams, - ) { - self.runtime_workspace_move_block("tui.workspace.move_block", params); - } - - pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { - let Some(ws_idx) = self.state.active else { - return; - }; - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return; - }; - self.runtime_tab_focus("tui.tab.focus", tab_id); - } - - pub(crate) fn close_active_tab_via_api_requires_confirmation(&mut self) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - if self - .state - .workspaces - .get(ws_idx) - .is_some_and(|ws| ws.tabs.len() <= 1) - { - if self.state.confirm_implicit_worktree_group_close(ws_idx) { - return true; - } - self.close_workspace_idx_with_group_via_api(ws_idx); - return false; - } - let tab_idx = self.state.workspaces[ws_idx].active_tab_index(); - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return false; - }; - self.runtime_tab_close("tui.tab.close", tab_id); - false - } - - pub(crate) fn move_tab_via_api( - &mut self, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - ) { - let Some(tab_id) = self.public_tab_id(ws_idx, source_tab_idx) else { - return; - }; - self.runtime_tab_move( - "tui.tab.move", - crate::api::schema::TabMoveParams { - tab_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn focus_pane_internal_via_api( - &mut self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) { - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return; - }; - self.runtime_pane_focus("tui.pane.focus", pane_id); - } - - pub(crate) fn focus_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, target)) = self.directional_pane_target_from_view(direction) { - self.focus_pane_internal_via_api(ws_idx, target); - return; - } - self.runtime_pane_focus_direction( - "tui.pane.focus_direction", - crate::api::schema::PaneFocusDirectionParams { - pane_id: None, - direction: api_pane_direction(direction), - }, - ); - } - - fn focus_pane_direction_in_context(&mut self, direction: NavDirection, context: ActionContext) { - let preserve_navigate_mode = - context == ActionContext::Navigate && self.state.mode == Mode::Navigate; - self.focus_pane_direction_via_api(direction); - if preserve_navigate_mode { - self.state.mode = Mode::Navigate; - } - } - - pub(crate) fn resize_pane_direction_via_api(&mut self, direction: NavDirection) { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: api_pane_direction(direction), - amount: None, - }, - ); - } - - pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) { - let source_pane_id = self.public_pane_id(ws_idx, source); - let target_pane_id = self.public_pane_id(ws_idx, target); - if let (Some(source_pane_id), Some(target_pane_id)) = (source_pane_id, target_pane_id) { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_pane_id), - target_pane_id: Some(target_pane_id), - }, - ); - return; - } - } - self.runtime_pane_swap( - "tui.pane.swap", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: Some(api_pane_direction(direction)), - source_pane_id: None, - target_pane_id: None, - }, - ); - } - - pub(crate) fn split_focused_pane_via_api( - &mut self, - direction: crate::api::schema::SplitDirection, - ) { - self.runtime_pane_split( - "tui.pane.split", - crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: None, - direction, - ratio: None, - cwd: None, - focus: true, - right_click: Default::default(), - env: Default::default(), - }, - ); - } - - pub(crate) fn close_focused_pane_via_api_requires_confirmation(&mut self) -> bool { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return false; - }; - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return false; - }; - self.runtime_pane_close("tui.pane.close", pane_id); - self.state.mode == Mode::ConfirmClose - } - - pub(crate) fn zoom_focused_pane_via_api(&mut self) { - self.runtime_pane_zoom( - "tui.pane.zoom", - crate::api::schema::PaneZoomParams { - pane_id: None, - mode: crate::api::schema::PaneZoomMode::Toggle, - }, - ); - } - - pub(crate) fn set_split_ratio_via_api(&mut self, path: Vec, ratio: f32) { - self.runtime_layout_set_split_ratio( - "tui.layout.set_split_ratio", - crate::api::schema::LayoutSetSplitRatioParams { - tab_id: None, - pane_id: None, - path, - ratio, - }, - ); - } - - pub(crate) fn cycle_pane_via_api(&mut self, reverse: bool) { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return; - }; - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return; - }; - let ids = tab.layout.pane_ids(); - let Some(pos) = ids.iter().position(|id| *id == pane_id) else { - return; - }; - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_internal_via_api(ws_idx, target); - } - - pub(crate) fn last_pane_via_api(&mut self) { - let Some(target) = self.state.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, _tab_idx)) = self.state.pane_focus_target_indices(&target) else { - self.state.previous_pane_focus = None; - return; - }; - if self.state.current_pane_focus_target().as_ref() == Some(&target) { - self.state.previous_pane_focus = None; - return; - } - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - } - - pub(crate) fn focus_toast_target_via_api(&mut self) { - let Some(target) = self - .state - .toast - .as_ref() - .and_then(|toast| toast.target.clone()) - else { - return; - }; - let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - self.state.toast = None; - self.state.mode = Mode::Terminal; - } - - fn focused_pane_target(&self) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; - Some((ws_idx, pane_id)) - } - - fn directional_pane_target_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, target)) - } - - fn directional_pane_swap_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, focused.id, target)) - } - - fn relative_visible_workspace(&self, delta: isize) -> Option { - let order = self.state.visible_workspace_order(); - if order.is_empty() { - return None; - } - let current = self.state.active.unwrap_or(self.state.selected); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = (current_pos as isize + delta).rem_euclid(order.len() as isize) as usize; - order.get(next).copied() - } - - fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> { - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let source = ws.active_tab; - let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?; - Some((ws_idx, source, insert)) - } - - fn relative_tab(&self, delta: isize) -> Option { - let ws = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx))?; - if ws.tabs.is_empty() { - return None; - } - Some((ws.active_tab as isize + delta).rem_euclid(ws.tabs.len() as isize) as usize) - } - - fn agent_entry_target(&self, idx: usize) -> Option<(usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - let target = entries.get(idx)?; - Some((target.ws_idx, target.pane_id)) - } - - fn relative_agent_entry(&self, forward: bool) -> Option<(usize, usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - if entries.is_empty() { - return None; - } - let focused = self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = entries - .iter() - .position(|entry| Some(entry.pane_id) == focused); - let next_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - let target = entries.get(next_idx)?; - Some((next_idx, target.ws_idx, target.pane_id)) - } - - fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - let Some(rt) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - else { - return false; - }; - - let bytes = rt.encode_terminal_key(key.clone()); - if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { - return false; - } - - self.state.mode = Mode::Terminal; - true - } - - pub(super) fn launch_custom_command( - &mut self, - binding: crate::config::CustomCommandKeybind, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - let previous_toast = self.state.toast.clone(); - let result = match binding.action { - crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), - crate::config::CustomCommandAction::Pane => { - self.spawn_pane_command(&binding.command, Vec::new()) - } - crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding), - crate::config::CustomCommandAction::PluginAction => self - .invoke_plugin_action_from_keybind(binding.command.clone()) - .map_err(std::io::Error::other), - }; - match result { - Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "custom command failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - finish_custom_command_context(&mut self.state, context, previous_mode); - } - } - } - - fn spawn_custom_popup_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> io::Result<()> { - self.spawn_popup_shell_command( - &binding.command, - None, - self.custom_command_env().0, - crate::app::popup::PopupGeometry { - width: binding.width, - height: binding.height, - }, - ) - } - - pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { - let mut env = vec![( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - )]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - - let mut cwd = None; - if let Some(ws_idx) = self.state.active { - env.push(( - "HERDR_ACTIVE_WORKSPACE_ID".to_string(), - self.public_workspace_id(ws_idx), - )); - if let Some(workspace) = self.state.workspaces.get(ws_idx) { - let tab_idx = workspace.active_tab_index(); - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); - } - if let Some(pane_id) = workspace.focused_pane_id() { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); - } - if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) - }) { - env.push(( - "HERDR_ACTIVE_PANE_CWD".to_string(), - pane_cwd.display().to_string(), - )); - if pane_cwd.is_dir() { - cwd = Some(pane_cwd); - } - } - } - } - } - (env, cwd) - } - - fn spawn_custom_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> std::io::Result<()> { - let mut command = crate::platform::detached_custom_command_process(&binding.command); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let (env, cwd) = self.custom_command_env(); - command.envs(env); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let child = command.spawn()?; - self.detached_process_children.push(child); - Ok(()) - } - - pub(super) fn launch_focused_scrollback_editor(&mut self) { - let previous_toast = self.state.toast.clone(); - match self.open_focused_scrollback_in_editor() { - Ok(()) => self.sync_toast_deadline(previous_toast), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "edit scrollback failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - } - } - } - - fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { - let ws_idx = self - .state - .active - .ok_or_else(|| std::io::Error::other("no active workspace"))?; - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let pane_id = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let scrollback = self - .state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) - .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? - .recent_unwrapped_text_snapshot(usize::MAX) - .text; - - let path = write_scrollback_temp_file(&scrollback)?; - - let argv = match crate::platform::scrollback_editor_argv(&path) { - Ok(argv) => argv, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let (env, _) = self.custom_command_env(); - let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { - Ok((_, new_pane)) => new_pane, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let terminal_id = new_pane.terminal.id.clone(); - self.terminal_runtimes - .insert(terminal_id.clone(), new_pane.runtime); - self.state - .remove_alias_shadowed_by_new_pane(new_pane.pane_id); - self.state.terminals.insert(terminal_id, new_pane.terminal); - - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "opened scrollback".to_string(), - context: format!("focused pane {public_pane_id}"), - position: None, - target: None, - }); - } - Ok(()) - } - - fn spawn_pane_command( - &mut self, - command: &str, - temp_files: Vec, - ) -> std::io::Result<()> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - let (env, _) = self.custom_command_env(); - - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let tab_idx = ws.active_tab_index(); - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }); - let new_pane = ws.split_focused_command( - Direction::Horizontal, - new_rows, - new_cols, - cwd, - command, - env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - )?; - let new_pane_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id: ws.id.clone(), - pane_id: new_pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - ws.active_tab_mut() - .expect("workspace must have an active tab") - .layout - .focus_pane(new_pane_id); - ws.active_tab_mut() - .expect("workspace must have an active tab") - .zoomed = true; - self.overlay_panes.insert( - new_pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - self.state.remove_alias_shadowed_by_new_pane(new_pane_id); - self.state.mode = Mode::Terminal; - Ok(()) - } - - pub(crate) fn spawn_overlay_argv_command( - &mut self, - argv: &[String], - cwd: Option, - extra_env: Vec<(String, String)>, - temp_files: Vec, - ) -> std::io::Result<(usize, crate::workspace::NewPane)> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let cwd = cwd.or_else(|| { - ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }) - }); - - let (tab_idx, new_pane, workspace_id) = { - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let result = ws.split_pane_argv_command( - previous_focus, - Direction::Horizontal, - new_rows, - new_cols, - cwd, - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - true, - ); - let (tab_idx, new_pane) = match result { - Some(Ok(result)) => result, - Some(Err(err)) => return Err(err), - None => return Err(std::io::Error::other("focused pane disappeared")), - }; - ws.tabs - .get_mut(tab_idx) - .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? - .zoomed = true; - self.overlay_panes.insert( - new_pane.pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - (tab_idx, new_pane, ws.id.clone()) - }; - - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id, - pane_id: new_pane.pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = Mode::Terminal; - Ok((ws_idx, new_pane)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum BindingDispatch { - Direct, - Prefix, -} - -enum PrefixBindingMatch { - Action(NavigateAction), - Command(crate::config::CustomCommandKeybind), -} - -fn prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option { - exact_prefix_binding_for_key(state, key).or_else(|| { - generated_character_key(key) - .as_ref() - .and_then(|generated_key| exact_prefix_binding_for_key(state, generated_key)) - }) -} - -fn exact_prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option { - non_indexed_action_for_key(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - .or_else(|| { - command_for_key(state, key, BindingDispatch::Prefix).map(PrefixBindingMatch::Command) - }) - .or_else(|| { - indexed_navigation_action(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - }) -} - -fn generated_character_key(key: &TerminalKey) -> Option { - let mut characters = key.generated_text.as_deref()?.chars(); - let character = characters.next()?; - if character.is_control() || characters.next().is_some() { - return None; - } - Some(TerminalKey::new( - KeyCode::Char(character), - crossterm::event::KeyModifiers::empty(), - )) -} - -pub(crate) fn command_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - state - .keybinds - .custom_commands - .iter() - .find(|binding| match dispatch { - BindingDispatch::Direct => binding.bindings.matches_direct_key(key), - BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key), - }) - .cloned() -} - -fn unmodified_digit_for_key(key: &TerminalKey) -> Option { - ('1'..='9').find(|digit| { - crate::config::terminal_key_matches_combo( - key, - ( - KeyCode::Char(*digit), - crossterm::event::KeyModifiers::empty(), - ), - ) - }) -} - -#[cfg(test)] -pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(&key) { - let idx = (c as usize) - ('1' as usize); - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - return true; - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); - } - return true; - } - KeyCode::Tab => { - state.cycle_pane(false); - return true; - } - KeyCode::BackTab => { - state.cycle_pane(true); - return true; - } - KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - return true; - } - KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - return true; - } - _ => {} - } - } - - if state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(-1); - return true; - } - if state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(1); - return true; - } - if state.keybinds.navigate.pane_left.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Left); - return true; - } - if state.keybinds.navigate.pane_down.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Down); - return true; - } - if state.keybinds.navigate.pane_up.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Up); - return true; - } - if state.keybinds.navigate.pane_right.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Right); - return true; - } - - false -} - -fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option { - if let Some(c) = unmodified_digit_for_key(key) { - return Some(NavigateAction::SwitchWorkspace( - (c as usize) - ('1' as usize), - )); - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - return (!state.workspaces.is_empty()).then_some(NavigateAction::SwitchWorkspace( - state - .visible_workspace_order() - .iter() - .position(|idx| *idx == state.selected) - .unwrap_or(state.selected), - )); - } - KeyCode::Tab => return Some(NavigateAction::CyclePaneNext), - KeyCode::BackTab => return Some(NavigateAction::CyclePanePrevious), - KeyCode::Left => return Some(NavigateAction::FocusPaneLeft), - KeyCode::Right => return Some(NavigateAction::FocusPaneRight), - _ => {} - } - } - - if state.keybinds.navigate.workspace_up.matches_direct_key(key) - || state - .keybinds - .navigate - .workspace_down - .matches_direct_key(key) - { - return None; - } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneLeft); - } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneDown); - } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneUp); - } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneRight); - } - - None -} - -pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema::PaneDirection { - match direction { - NavDirection::Left => crate::api::schema::PaneDirection::Left, - NavDirection::Right => crate::api::schema::PaneDirection::Right, - NavDirection::Up => crate::api::schema::PaneDirection::Up, - NavDirection::Down => crate::api::schema::PaneDirection::Down, - } -} - -#[cfg(test)] -pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_dismissed = true; - let terminal_key = TerminalKey::from(key); - - if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if handle_navigate_reserved_key(state, terminal_key.clone()) { - return; - } - - if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigateAction { - NewWorkspace, - NewWorktree, - OpenWorktree, - RemoveWorktree, - RenameWorkspace, - CloseWorkspace, - SwitchWorkspace(usize), - SwitchTab(usize), - FocusAgent(usize), - WorkspacePicker, - PreviousWorkspace, - NextWorkspace, - PreviousAgent, - NextAgent, - NewTab, - RenameTab, - PreviousTab, - NextTab, - MoveTabPrevious, - MoveTabNext, - CloseTab, - RenamePane, - FocusPaneLeft, - FocusPaneDown, - FocusPaneUp, - FocusPaneRight, - SwapPaneLeft, - SwapPaneDown, - SwapPaneUp, - SwapPaneRight, - SplitVertical, - SplitHorizontal, - ClosePane, - EditScrollback, - CopyMode, - Zoom, - EnterResizeMode, - ResizePaneLeft, - ResizePaneDown, - ResizePaneUp, - ResizePaneRight, - ToggleSidebar, - CyclePaneNext, - CyclePanePrevious, - LastPane, - Help, - Settings, - ReloadConfig, - OpenNotificationTarget, - Detach, - OpenNavigator, -} - -fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { - matches!( - action, - NavigateAction::SwitchWorkspace(_) - | NavigateAction::SwitchTab(_) - | NavigateAction::FocusAgent(_) - | NavigateAction::PreviousWorkspace - | NavigateAction::NextWorkspace - | NavigateAction::PreviousAgent - | NavigateAction::NextAgent - | NavigateAction::PreviousTab - | NavigateAction::NextTab - | NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - | NavigateAction::CyclePaneNext - | NavigateAction::CyclePanePrevious - | NavigateAction::LastPane - | NavigateAction::OpenNotificationTarget - ) -} - -fn indexed_navigation_action( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - let kb = &state.keybinds; - let actual_modifiers = crate::config::normalize_key_combo((key.code, key.modifiers)).1; - - for exact_modifiers in [true, false] { - let trigger_matches = |binding: &crate::config::IndexedKeybind| { - let dispatch_matches = match dispatch { - BindingDispatch::Direct => binding.trigger.is_direct(), - BindingDispatch::Prefix => binding.trigger.is_prefix(), - }; - let expected_modifiers = crate::config::normalize_key_combo(binding.trigger.combo()).1; - dispatch_matches && (actual_modifiers == expected_modifiers) == exact_modifiers - }; - - for binding in &kb.switch_tab { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchTab(idx)); - } - } - } - for binding in &kb.switch_workspace { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchWorkspace(idx)); - } - } - } - for binding in &kb.focus_agent { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::FocusAgent(idx)); - } - } - } - } - - None -} - -fn action_matches( - bindings: &crate::config::ActionKeybinds, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> bool { - match dispatch { - BindingDispatch::Direct => bindings.matches_direct_key(key), - BindingDispatch::Prefix => bindings.matches_prefix_key(key), - } -} - -#[cfg(test)] -fn action_for_key( - state: &AppState, - key: TerminalKey, - dispatch: BindingDispatch, -) -> Option { - non_indexed_action_for_key(state, &key, dispatch) - .or_else(|| indexed_navigation_action(state, &key, dispatch)) -} - -fn non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option { - let kb = &state.keybinds; - for (bindings, action) in [ - (&kb.help, NavigateAction::Help), - (&kb.settings, NavigateAction::Settings), - (&kb.workspace_picker, NavigateAction::WorkspacePicker), - (&kb.new_workspace, NavigateAction::NewWorkspace), - (&kb.new_worktree, NavigateAction::NewWorktree), - (&kb.open_worktree, NavigateAction::OpenWorktree), - (&kb.remove_worktree, NavigateAction::RemoveWorktree), - (&kb.rename_workspace, NavigateAction::RenameWorkspace), - (&kb.close_workspace, NavigateAction::CloseWorkspace), - (&kb.previous_workspace, NavigateAction::PreviousWorkspace), - (&kb.next_workspace, NavigateAction::NextWorkspace), - (&kb.previous_agent, NavigateAction::PreviousAgent), - (&kb.next_agent, NavigateAction::NextAgent), - (&kb.new_tab, NavigateAction::NewTab), - (&kb.rename_tab, NavigateAction::RenameTab), - (&kb.previous_tab, NavigateAction::PreviousTab), - (&kb.next_tab, NavigateAction::NextTab), - (&kb.move_tab_previous, NavigateAction::MoveTabPrevious), - (&kb.move_tab_next, NavigateAction::MoveTabNext), - (&kb.close_tab, NavigateAction::CloseTab), - (&kb.rename_pane, NavigateAction::RenamePane), - (&kb.edit_scrollback, NavigateAction::EditScrollback), - (&kb.copy_mode, NavigateAction::CopyMode), - (&kb.focus_pane_left, NavigateAction::FocusPaneLeft), - (&kb.focus_pane_down, NavigateAction::FocusPaneDown), - (&kb.focus_pane_up, NavigateAction::FocusPaneUp), - (&kb.focus_pane_right, NavigateAction::FocusPaneRight), - (&kb.swap_pane_left, NavigateAction::SwapPaneLeft), - (&kb.swap_pane_down, NavigateAction::SwapPaneDown), - (&kb.swap_pane_up, NavigateAction::SwapPaneUp), - (&kb.swap_pane_right, NavigateAction::SwapPaneRight), - (&kb.last_pane, NavigateAction::LastPane), - (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), - (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), - (&kb.split_vertical, NavigateAction::SplitVertical), - (&kb.split_horizontal, NavigateAction::SplitHorizontal), - (&kb.close_pane, NavigateAction::ClosePane), - (&kb.zoom, NavigateAction::Zoom), - (&kb.resize_mode, NavigateAction::EnterResizeMode), - (&kb.resize_pane_left, NavigateAction::ResizePaneLeft), - (&kb.resize_pane_down, NavigateAction::ResizePaneDown), - (&kb.resize_pane_up, NavigateAction::ResizePaneUp), - (&kb.resize_pane_right, NavigateAction::ResizePaneRight), - (&kb.toggle_sidebar, NavigateAction::ToggleSidebar), - (&kb.reload_config, NavigateAction::ReloadConfig), - ( - &kb.open_notification_target, - NavigateAction::OpenNotificationTarget, - ), - (&kb.detach, NavigateAction::Detach), - (&kb.goto, NavigateAction::OpenNavigator), - ] { - if action_matches(bindings, key, dispatch) { - return Some(action); - } - } - None -} - -#[cfg(test)] -fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option { - let action = action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option { - let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option { - indexed_navigation_action(state, key, BindingDispatch::Prefix) -} - -#[cfg(test)] -pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); -} - -#[cfg(test)] -pub(super) fn execute_navigate_action_in_context( - state: &mut AppState, - terminal_runtimes: &mut TerminalRuntimeRegistry, - action: NavigateAction, - context: ActionContext, -) { - let previous_mode = state.mode; - match action { - NavigateAction::NewWorkspace => { - state.request_new_workspace = true; - leave_navigate_mode(state); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - super::modal::open_rename_workspace(state, terminal_runtimes, ws_idx); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.selected = ws_idx; - if state.confirm_close { - super::modal::open_confirm_close(state); - } else { - state.close_selected_workspace(); - leave_navigate_mode(state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::SwitchTab(idx) => { - let tab_exists = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()); - if tab_exists { - state.switch_tab(idx); - leave_navigate_mode(state); - } - } - NavigateAction::FocusAgent(idx) => { - if state.focus_agent_entry(idx) { - leave_navigate_mode(state); - } - } - NavigateAction::WorkspacePicker => { - state.mobile_switcher_scroll = 0; - state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - state.previous_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NextWorkspace => { - state.next_workspace(); - leave_navigate_mode(state); - } - NavigateAction::PreviousAgent => { - state.previous_agent(); - leave_navigate_mode(state); - } - NavigateAction::NextAgent => { - state.next_agent(); - leave_navigate_mode(state); - } - NavigateAction::NewTab => { - if state.active.is_some() { - if state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(state); - } else { - state.request_new_tab = true; - leave_navigate_mode(state); - } - } - } - NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), - NavigateAction::PreviousTab => { - state.previous_tab(); - leave_navigate_mode(state); - } - NavigateAction::NextTab => { - state.next_tab(); - leave_navigate_mode(state); - } - NavigateAction::MoveTabPrevious => { - move_active_tab_relative(state, -1); - leave_navigate_mode(state); - } - NavigateAction::MoveTabNext => { - move_active_tab_relative(state, 1); - leave_navigate_mode(state); - } - NavigateAction::CloseTab => { - if !state.close_tab() { - leave_navigate_mode(state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(state, pane_id); - } - } - NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), - NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), - NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), - NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), - NavigateAction::SwapPaneLeft => { - state.swap_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneDown => { - state.swap_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneUp => { - state.swap_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneRight => { - state.swap_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::SplitVertical => { - state.split_pane(terminal_runtimes, Direction::Horizontal); - leave_navigate_mode(state); - } - NavigateAction::SplitHorizontal => { - state.split_pane(terminal_runtimes, Direction::Vertical); - leave_navigate_mode(state); - } - NavigateAction::ClosePane => { - if !state.close_pane() { - leave_navigate_mode(state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => state.enter_copy_mode(terminal_runtimes), - NavigateAction::Zoom => { - state.toggle_zoom(); - leave_navigate_mode(state); - } - NavigateAction::EnterResizeMode => state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - state.resize_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneDown => { - state.resize_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneUp => { - state.resize_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneRight => { - state.resize_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::ToggleSidebar => { - state.sidebar_collapsed = !state.sidebar_collapsed; - leave_navigate_mode(state); - } - NavigateAction::CyclePaneNext => { - state.cycle_pane(false); - leave_navigate_mode(state); - } - NavigateAction::CyclePanePrevious => { - state.cycle_pane(true); - leave_navigate_mode(state); - } - NavigateAction::LastPane => { - state.last_pane(); - leave_navigate_mode(state); - } - NavigateAction::Help => super::modal::open_keybind_help(state), - NavigateAction::Settings => super::settings::open_settings(state), - NavigateAction::ReloadConfig => { - state.request_reload_config = true; - leave_navigate_mode(state); - } - NavigateAction::OpenNotificationTarget => { - state.focus_toast_target(); - if state.mode == Mode::Navigate { - leave_navigate_mode(state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(state); - leave_navigate_mode(state); - } - NavigateAction::OpenNavigator => state.open_navigator_from(terminal_runtimes), - } - - finish_action_context(state, context, previous_mode); -} - -fn workspace_action_target(state: &AppState, context: ActionContext) -> Option { - let idx = match context { - ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), - ActionContext::Navigate => state.selected, - }; - (idx < state.workspaces.len()).then_some(idx) -} - -fn workspace_can_start_worktree_action( - state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> bool { - let Some(ws) = state.workspaces.get(ws_idx) else { - return false; - }; - if ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return false; - } - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - !git_space.is_some_and(|space| space.is_linked_worktree) -} - -// Translate a one-step move into the pre-removal insertion slot that -// Workspace::move_tab expects, wrapping at either end. None when there is -// nothing to move. -fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option { - if len <= 1 { - return None; - } - Some(if delta > 0 { - if source + 1 >= len { - 0 - } else { - source + 2 - } - } else if source == 0 { - len - } else { - source - 1 - }) -} - -#[cfg(test)] -fn move_active_tab_relative(state: &mut AppState, delta: isize) { - let Some(ws) = state - .active - .and_then(|ws_idx| state.workspaces.get_mut(ws_idx)) - else { - return; - }; - let source = ws.active_tab; - if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) { - ws.move_tab(source, insert); - } -} - -fn leave_navigate_mode(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } -} - -fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) { - if matches!(context, ActionContext::Direct | ActionContext::Prefix) - && state.mode == previous_mode - { - leave_command_mode(state); - } -} - -fn finish_custom_command_context( - state: &mut AppState, - context: ActionContext, - previous_mode: Mode, -) { - if context == ActionContext::Navigate { - leave_navigate_mode(state); - } else { - finish_action_context(state, context, previous_mode); - } -} - -fn leave_command_mode(state: &mut AppState) { - if state.copy_mode_pane_is_focused() { - state.mode = Mode::Copy; - } else if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - }; -} - -fn write_scrollback_temp_file(content: &str) -> io::Result { - let mut last_collision = None; - for attempt in 0..16 { - let path = unique_scrollback_path(attempt); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - match options.open(&path) { - Ok(mut file) => { - file.write_all(content.as_bytes())?; - return Ok(path); - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - last_collision = Some(err); - } - Err(err) => return Err(err), - } - } - - Err(last_collision.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create unique scrollback temp file", - ) - })) -} - -fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!( - "herdr-scrollback-{}-{nanos}-{attempt}.txt", - std::process::id() - )) -} - -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::time::Duration; - - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode}; - use ratatui::layout::Direction; - - use super::super::{state_with_workspaces, unique_temp_path}; - #[cfg(unix)] - use super::super::{wait_for_detached_process_reap, wait_for_file}; - use super::*; - use crate::{ - app::App, - config::Config, - input::TerminalKey, - raw_input::{parse_raw_input_bytes_sync, RawInputEvent}, - terminal::TerminalState, - workspace::Workspace, - }; - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn next_agent_starts_at_first_visible_entry_when_focused_agent_is_filtered_out() { - let mut app = app_with_test_workspaces(&["hidden", "first", "second"]); - for ws_idx in 0..app.state.workspaces.len() { - let pane_id = app.state.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = if ws_idx == 0 { - crate::detect::AgentState::Idle - } else { - crate::detect::AgentState::Working - }; - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - app.execute_tui_navigate_action(NavigateAction::NextAgent, ActionContext::Prefix); - - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn default_goto_key_opens_navigator() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - } - - #[test] - fn custom_rename_key_enters_rename_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "test"); - } - - #[test] - fn rename_workspace_prefills_live_terminal_cwd_label() { - let mut state = state_with_workspaces(&["stale"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].panes[&root] - .attached_terminal_id - .clone(); - state.workspaces[0].custom_name = None; - state.workspaces[0].identity_cwd = "/__herdr_original__".into(); - state.terminals.insert( - terminal_id.clone(), - TerminalState::new(terminal_id, "/__herdr_projects__".into()), - ); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "__herdr_projects__"); - assert_eq!(state.workspaces[0].display_name(), "__herdr_original__"); - } - - #[test] - fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::RenameWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.selected, 1); - assert_eq!(state.name_input, "issue"); - } - - #[test] - fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::CloseWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_workspace_key_requests_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_new_workspace); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn new_workspace_key_opens_prefilled_prompt_and_preserves_captured_cwd() { - let cwd = unique_temp_path("workspace-name-suggestion"); - std::fs::create_dir_all(&cwd).unwrap(); - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - app.state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert_eq!(app.state.name_input, suggested_name); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.pending_workspace_create_cwd.as_ref(), Some(&cwd)); - assert_eq!(app.state.workspaces.len(), 1); - - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path("/tmp/changed-after-prompt".into()); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - assert!(app.state.workspaces[1].custom_name.is_none()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[tokio::test] - async fn new_workspace_prompt_saves_custom_name_atomically() { - let cwd = unique_temp_path("workspace-custom-name"); - std::fs::create_dir_all(&cwd).unwrap(); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.state.name_input = " logs ".into(); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].custom_name.as_deref(), Some("logs")); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[test] - fn cancelling_new_workspace_prompt_creates_nothing() { - let mut app = app_with_test_workspaces(&["test"]); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_worktree_key_requests_selected_workspace() { - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.request_new_linked_worktree, Some(1)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn worktree_actions_do_not_start_from_linked_child_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_new_linked_worktree, None); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::OpenWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_open_existing_worktree, None); - } - - #[test] - fn direct_new_worktree_action_targets_active_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); - state.mode = Mode::Terminal; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Direct, - ); - - assert_eq!(state.request_new_linked_worktree, Some(0)); - } - - #[test] - fn navigate_down_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 2); - } - - #[test] - fn navigate_number_keys_follow_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Prefix; - state.active = Some(0); - state.selected = 0; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::SwitchWorkspace(1), - ActionContext::Prefix, - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); - assert!(!state.sidebar_collapsed); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.sidebar_collapsed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_enters_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Resize); - } - - #[test] - fn custom_reload_config_key_requests_reload_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_reload_config); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_open_notification_key_focuses_current_toast_target() { - let mut state = state_with_workspaces(&["one", "two"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g"); - let target_workspace_id = state.workspaces[1].id.clone(); - let target_pane = state.workspaces[1].tabs[0].root_pane; - state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".into(), - context: "two".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: target_workspace_id, - pane_id: target_pane, - }), - }); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.selected, 1); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(state.toast.is_none()); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn movement_action_stays_in_navigate_mode() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_workspace_keys_are_configurable() { - let mut state = state_with_workspaces(&["a", "b"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_pane_keys_are_configurable() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -focus_pane_down = "prefix+f" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_left = "ctrl+h" -navigate_pane_right = "ctrl+l" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { - let mut state = state_with_workspaces(&["a", "b", "c", "d"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8)); - assert_eq!(state.mobile_switcher_scroll, 0); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mobile_switcher_scroll, 1); - } - - #[test] - fn terminal_direct_agent_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::NextAgent)); - } - - #[test] - fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); - } - - #[test] - fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::SwapPaneRight)); - } - - #[test] - fn terminal_direct_resize_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_pane_right = - crate::config::ActionKeybinds::direct("ctrl+shift+alt+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new( - KeyCode::Right, - KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT, - ), - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneRight)); - } - - #[test] - fn prefix_resize_pane_binding_maps_to_navigation_action() { - let config: Config = toml::from_str( - r#" -[keys] -resize_pane_left = "prefix+shift+left" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::SHIFT), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneLeft)); - } - - #[test] - fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::MoveTabNext)); - } - - fn tab_labels(state: &AppState) -> Vec { - let ws = &state.workspaces[0]; - (0..ws.tabs.len()) - .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) - .collect() - } - - #[test] - fn move_tab_actions_reorder_and_wrap_the_active_tab() { - let mut state = state_with_workspaces(&["test"]); - { - let ws = &mut state.workspaces[0]; - ws.tabs[0].set_custom_name("a".into()); - ws.test_add_tab(Some("b")); - ws.test_add_tab(Some("c")); - ws.switch_tab(1); - } - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["b", "a", "c"]); - assert_eq!(state.workspaces[0].active_tab, 0); - - execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - state.workspaces[0].assert_invariants_for_test(); - } - - #[test] - fn move_tab_is_a_noop_with_a_single_tab() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].tabs[0].set_custom_name("only".into()); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - - assert_eq!(tab_labels(&state), vec!["only"]); - assert_eq!(state.workspaces[0].active_tab, 0); - } - - #[test] - fn move_tab_with_a_single_tab_still_exits_navigate_mode() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - event_hub, - ); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::LastPane)); - } - - #[test] - fn generated_character_prefix_binding_falls_back_after_exact_chord() { - let generated_key = crate::input::parse_terminal_key_sequence("\x1b[119;3;124u").unwrap(); - assert_eq!(generated_key.code, KeyCode::Char('w')); - assert_eq!(generated_key.modifiers, KeyModifiers::ALT); - assert_eq!(generated_key.generated_text.as_deref(), Some("|")); - - let generated_only: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = generated_only.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitVertical)) - )); - let multi_character_key = - crate::input::parse_terminal_key_sequence("\x1b[119;3;124:120u").unwrap(); - assert!(prefix_binding_for_key(&state, &multi_character_key).is_none()); - - let exact_and_generated: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = "prefix+alt+w" -"#, - ) - .unwrap(); - state.keybinds = exact_and_generated.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitHorizontal)) - )); - - let exact_command: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" - -[[keys.command]] -key = "prefix+alt+w" -command = "echo exact" -"#, - ) - .unwrap(); - state.keybinds = exact_command.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Command(binding)) if binding.command == "echo exact" - )); - } - - #[test] - fn shifted_backslash_layout_prefers_horizontal_split_binding() { - let config: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = 'prefix+\' -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - let key = crate::input::parse_terminal_key_sequence("\x1b[124:92;2:1u").unwrap(); - assert_eq!(key.code, KeyCode::Char('|')); - assert_eq!(key.modifiers, KeyModifiers::SHIFT); - assert_eq!(key.shifted_codepoint, Some('\\' as u32)); - assert!(state.keybinds.split_horizontal.matches_prefix_key(&key)); - assert!(!state.keybinds.split_vertical.matches_prefix_key(&key)); - - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SplitHorizontal) - ); - assert_eq!( - action_for_key( - &state, - TerminalKey::new(KeyCode::Char('|'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ), - Some(NavigateAction::SplitVertical) - ); - } - - #[test] - fn prefix_tab_override_can_map_to_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let pane_action = action_for_key( - &state, - TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(pane_action, Some(NavigateAction::LastPane)); - } - - #[test] - fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL), - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(2))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_legacy_us_symbol_key() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('@'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchWorkspace(1))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_non_us_number_rows() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - for key in [ - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ] { - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(1)) - ); - } - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_survives_modifier_press() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - app.state.keybinds.switch_workspace = config.keybinds().switch_workspace; - app.state.mode = Mode::Prefix; - - app.handle_prefix_key(TerminalKey::new( - KeyCode::Modifier(ModifierKeyCode::LeftShift), - KeyModifiers::SHIFT, - )); - - assert_eq!(app.state.mode, Mode::Prefix); - - app.handle_prefix_key( - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn prefix_unshifted_indexed_shortcut_maps_shifted_french_number_row() { - let mut state = state_with_workspaces(&["one"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"prefix+1..9\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(1))); - } - - #[test] - fn literal_symbol_binding_takes_precedence_over_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -help = "prefix+!" -switch_workspace = "prefix+shift+1..9" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::Help)); - } - - #[test] - fn literal_symbol_custom_command_is_visible_before_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "echo literal" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); - assert_eq!( - indexed_navigation_action(&state, &key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(0)) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn literal_symbol_custom_command_runs_before_shifted_indexed_alias() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("literal-symbol-custom-command"); - let config: Config = toml::from_str(&format!( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "printf literal > '{}'" -"#, - output_path.display() - )) - .unwrap(); - app.state.keybinds = config.keybinds(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty())) - .await; - - assert_eq!(wait_for_file(&output_path), "literal"); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_mode_matches_legacy_uppercase_shifted_letter() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_workspace_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('W'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - } - - #[tokio::test] - async fn kitty_shifted_alternate_without_modifier_prefers_reload_over_resize() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - - let mut events = parse_raw_input_bytes_sync(b"\x1b[114:82;1u"); - assert_eq!(events.len(), 1); - let RawInputEvent::Key(key) = events.remove(0) else { - panic!("expected key event"); - }; - assert_eq!( - action_for_key(&app.state, key.clone(), BindingDispatch::Prefix), - Some(NavigateAction::ReloadConfig) - ); - app.handle_prefix_key(key); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); - - assert!(!app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_pane_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('P'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenamePane); - } - - #[test] - fn app_navigate_mode_workspace_down_moves_selection() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn app_navigate_mode_maps_french_number_row_to_workspace() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key( - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn app_navigate_mode_workspace_keys_are_configurable() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let root = app.state.workspaces[0].tabs[0].root_pane; - let right = app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].layout.focus_pane(right); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_focus_pane_keeps_navigate_mode_active() { - let mut app = app_with_test_workspaces(&["test"]); - let root = app.state.workspaces[0].tabs[0].root_pane; - let below = app.state.workspaces[0].test_split(Direction::Vertical); - app.state.workspaces[0].layout.focus_pane(below); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - app.state.mode = Mode::Navigate; - - app.handle_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn no_op_prefix_action_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn unmatched_prefix_rhs_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn prefix_help_matches_enhanced_shifted_question_mark() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ) - .await; - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn navigate_mode_help_is_binding_driven() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.help = crate::config::ActionKeybinds::prefix("f"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - assert_eq!(state.mode, Mode::Navigate); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT), - ); - - assert!(state.sidebar_collapsed); - } - - #[test] - fn empty_state_new_tab_is_no_op() { - let mut state = crate::app::state::AppState::test_new(); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewTab, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::Navigate); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.workspaces.is_empty()); - } - - #[test] - fn closing_linked_worktree_closes_workspace_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.active = Some(1); - state.mode = Mode::Navigate; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn tui_close_parent_group_closes_immediately_when_confirmation_disabled() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - app.state.confirm_close = false; - - app.execute_tui_navigate_action(NavigateAction::CloseWorkspace, ActionContext::Navigate); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn prefix_close_pane_last_parent_group_pane_opens_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.selected = 1; - state.active = Some(0); - state.mode = Mode::Navigate; - - execute_navigate_action(&mut state, NavigateAction::ClosePane); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn tui_close_tab_last_parent_group_workspace_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::CloseTab, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn tui_close_pane_last_parent_group_pane_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::ClosePane, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[cfg(unix)] - #[tokio::test] - async fn custom_command_runs_from_prefix_key_in_navigate_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-command-keybind"); - let release_path = unique_temp_path("custom-command-release"); - let command = format!( - "printf '%s\\n%s\\n%s\\n%s\\n' \"$$\" \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'; i=0; while [ ! -e '{}' ] && [ \"$i\" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done", - output_path.display(), - release_path.display(), - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - - let launch_started = std::time::Instant::now(); - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - assert!(launch_started.elapsed() < Duration::from_secs(2)); - - let content = wait_for_file(&output_path); - let lines: Vec<&str> = content.lines().collect(); - assert_eq!(lines.len(), 4); - let pid = lines[0] - .parse::() - .expect("command should report its pid"); - assert!(crate::platform::process_exists(pid)); - assert_eq!(lines[1], app.state.workspaces[0].id); - assert_eq!(lines[2], format!("{}:t1", app.state.workspaces[0].id)); - assert_eq!(lines[3], format!("{}:p1", app.state.workspaces[0].id)); - assert_eq!(app.state.mode, Mode::Terminal); - - std::fs::write(&release_path, b"release").expect("release command"); - let reaped_by_runtime = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped_by_runtime { - if let Some(child) = app - .detached_process_children - .iter_mut() - .find(|child| child.id() == pid) - { - let _ = child.kill(); - let _ = child.wait(); - } - } - assert!( - reaped_by_runtime, - "detached command child {pid} was not reaped" - ); - - let _ = std::fs::remove_file(output_path); - let _ = std::fs::remove_file(release_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn pane_overlay_command_opens_and_closes_after_exit() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - let root_pane = workspace.tabs[0].root_pane; - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-pane-command"); - let command = format!("printf done > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - assert_ne!(overlay_pane, root_pane); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); - - app.state.last_pane(); - - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(overlay_pane) - ); - - let _ = wait_for_file(&output_path); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if app.drain_internal_events() - && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 - { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn edit_scrollback_key_preserves_logical_lines_in_editor_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 5, - 5, - 4096, - b"ABCDEFGHIJ\r\nKLMNO", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g"); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert_eq!(content, "ABCDEFGHIJ\nKLMNO"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!( - app.state.terminals.values().any(|terminal| terminal - .launch_argv - .as_ref() - .is_some_and(|argv| argv.first().is_some_and(|program| program == "/bin/sh"))), - "scrollback editor should launch through argv overlay path" - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn zoom_action_exits_navigate_mode() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn focus_pane_action_keeps_zoomed_when_changing_focus() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(root); - state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); - - execute_navigate_action(&mut state, NavigateAction::FocusPaneRight); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn question_mark_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn new_tab_action_opens_dialog_without_creating_tab() { - let mut state = state_with_workspaces(&["test"]); - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::RenameTab); - assert!(state.creating_new_tab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - assert!(!state.request_new_tab); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn new_tab_action_can_skip_rename_dialog() { - let mut state = state_with_workspaces(&["test"]); - state.prompt_new_tab_name = false; - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn navigate_q_detaches_in_persistence_mode() { - let mut state = crate::app::state::AppState::test_new(); - state.detach_exits = false; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), - ); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } -} diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs deleted file mode 100644 index 58f2ef71..00000000 --- a/src/app/input/overlays.rs +++ /dev/null @@ -1,831 +0,0 @@ -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - layout::Rect, - widgets::{Block, Borders}, -}; - -use crate::app::{ - state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, - App, -}; - -use super::{ - modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction}, - ScrollbarClickTarget, -}; - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height -} - -impl App { - pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { - if self.state.mode == Mode::ReleaseNotes { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .release_notes_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_release_notes(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .release_notes_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_release_notes(-3), - MouseEventKind::ScrollDown => self.scroll_release_notes(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::ProductAnnouncement { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .product_announcement_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_product_announcement(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .product_announcement_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => self - .state - .set_product_announcement_offset_from_bottom(offset_from_bottom), - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_product_announcement_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_product_announcement(-3), - MouseEventKind::ScrollDown => self.scroll_product_announcement(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::Navigator { - match mouse.kind { - MouseEventKind::Moved => { - if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - self.state - .ensure_navigator_selection_visible_from(&self.terminal_runtimes); - } - } - MouseEventKind::Down(MouseButton::Left) => { - if self - .state - .navigator_search_contains(mouse.column, mouse.row) - { - self.state.navigator.search_focused = true; - } else if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - let target = self - .state - .navigator_rows_from(&self.terminal_runtimes) - .get(idx) - .map(|row| (row.target.clone(), row.is_workspace)); - if let Some((NavigatorTarget::Workspace { .. }, true)) = target { - if self.state.navigator_row_caret_at(mouse.column) { - self.state.toggle_selected_navigator_workspace_from( - &self.terminal_runtimes, - ); - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { - leave_modal(&mut self.state); - } - } - MouseEventKind::ScrollUp => { - self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - MouseEventKind::ScrollDown => { - let viewport = self.state.navigator_body_rect().height as usize; - let max = self - .state - .navigator_max_scroll_from(&self.terminal_runtimes, viewport); - self.state.navigator.scroll = - self.state.navigator.scroll.saturating_add(3).min(max); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - _ => {} - } - return true; - } - - if self.state.mode == Mode::KeybindHelp { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .keybind_help_close_button_at(mouse.column, mouse.row) => - { - keybind_help_back(&mut self.state); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .keybind_help_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } else { - let rect = self.state.keybind_help_popup_rect(); - let inside = mouse.column >= rect.x - && mouse.column < rect.x + rect.width - && mouse.row >= rect.y - && mouse.row < rect.y + rect.height; - if !inside { - leave_modal(&mut self.state); - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), - MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), - _ => {} - } - return true; - } - - false - } -} - -impl AppState { - pub(super) fn onboarding_full_area(&self) -> Rect { - self.view.sidebar_rect.union(self.view.terminal_area) - } - - pub(crate) fn navigator_popup_rect(&self) -> Rect { - let area = self.onboarding_full_area(); - let margin_x = (area.width / 16).max(2); - let margin_y = (area.height / 10).max(1); - let width = area.width.saturating_sub(margin_x.saturating_mul(2)); - let height = area.height.saturating_sub(margin_y.saturating_mul(2)); - Rect::new( - area.x + margin_x, - area.y + margin_y, - width.max(4), - height.max(4), - ) - } - - pub(crate) fn navigator_inner_rect(&self) -> Rect { - Block::default() - .borders(Borders::ALL) - .inner(self.navigator_popup_rect()) - } - - pub(crate) fn navigator_search_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) - } - - pub(crate) fn navigator_body_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - if inner.height <= 4 { - return Rect::default(); - } - Rect::new( - inner.x, - inner.y + 2, - inner.width, - inner.height.saturating_sub(4), - ) - } - - pub(crate) fn navigator_detail_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_footer_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(1), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_popup_rect(), col, row) - } - - pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_search_rect(), col, row) - } - - pub(crate) fn navigator_row_index_at_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option { - let body = self.navigator_body_rect(); - if !rect_contains(body, col, row) { - return None; - } - let line_idx = self - .navigator - .scroll - .saturating_add(row.saturating_sub(body.y) as usize); - let lines = crate::app::state::navigator_display_lines( - &self.navigator_rows_from(terminal_runtimes), - ); - match lines.get(line_idx) { - Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx), - _ => None, - } - } - - pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { - let body = self.navigator_body_rect(); - col <= body.x.saturating_add(3) - } - - pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option { - let area = self.onboarding_full_area(); - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { - return None; - } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); - Some(Block::default().borders(Borders::ALL).inner(popup)) - } - - fn release_notes_modal_inner(&self) -> Option { - self.onboarding_modal_inner( - crate::ui::RELEASE_NOTES_MODAL_SIZE.0, - crate::ui::RELEASE_NOTES_MODAL_SIZE.1, - ) - } - - fn product_announcement_modal_inner(&self) -> Option { - self.onboarding_modal_inner( - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - ) - } - - fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.release_notes_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - pub(super) fn rename_modal_inner(&self) -> Option { - self.onboarding_modal_inner(56, 7) - } - - fn release_notes_body_rect(&self) -> Option { - let inner = self.release_notes_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn release_notes_scroll_metrics(&self) -> Option { - let notes = self.release_notes.as_ref()?; - let body = self.release_notes_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::release_notes_display_lines( - notes, - &self.update_install_command, - &self.palette, - ); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - let mut total_rows = rows_for_width(full_width); - let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) - } else { - full_width - }; - total_rows = rows_for_width(wrap_width); - - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn release_notes_max_scroll(&self) -> u16 { - self.release_notes_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn release_notes_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.release_notes_max_scroll() as usize; - if let Some(notes) = &mut self.release_notes { - notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.product_announcement_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn product_announcement_body_rect(&self) -> Option { - let inner = self.product_announcement_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn product_announcement_scroll_metrics(&self) -> Option { - let announcement = self.product_announcement.as_ref()?; - let body = self.product_announcement_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::product_announcement_display_lines(announcement, &self.palette); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - let mut total_rows = rows_for_width(full_width); - let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) - } else { - full_width - }; - total_rows = rows_for_width(wrap_width); - - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom.saturating_sub(announcement.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn product_announcement_max_scroll(&self) -> u16 { - self.product_announcement_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn product_announcement_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn product_announcement_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.product_announcement_max_scroll() as usize; - if let Some(announcement) = &mut self.product_announcement { - announcement.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - let Some(inner) = self.onboarding_modal_inner(64, 16) else { - return; - }; - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap_or_default(); - let button = crate::ui::onboarding_welcome_continue_rect(actions); - if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)]) - == Some(ModalAction::Continue) - { - self.request_complete_onboarding = true; - } - } - - pub(super) fn keybind_help_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() - } - - fn keybind_help_modal_inner(&self) -> Option { - self.onboarding_modal_inner(76, 22) - } - - fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.keybind_help_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn keybind_help_body_rect(&self) -> Option { - let inner = self.keybind_help_modal_inner()?; - if inner.height < 6 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn keybind_help_scroll_metrics(&self) -> Option { - let body = self.keybind_help_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let wrap_width = body.width.max(1) as usize; - let total_rows = crate::ui::keybind_help_lines(self) - .into_iter() - .map(|(width, _)| width.max(1).div_ceil(wrap_width)) - .sum::(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom - .saturating_sub(self.keybind_help.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(crate) fn keybind_help_max_scroll(&self) -> u16 { - self.keybind_help_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.keybind_help_max_scroll() as usize; - self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - - pub(super) fn scroll_keybind_help(&mut self, delta: i16) { - let max_scroll = self.keybind_help_max_scroll(); - let current = self.keybind_help.scroll as i16; - self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - - #[test] - fn clicking_keybind_help_close_button_closes_overlay() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let close = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_keybind_help_back_button_leaves_help_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - app.state.keybind_help.search_focused = true; - app.state.keybind_help.query = "work".into(); - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let back = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - back.x, - back.y, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - assert!(!app.state.keybind_help.search_focused); - assert!(app.state.keybind_help.query.is_empty()); - } - - #[test] - fn onboarding_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content; - app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); - - assert!(!app.state.request_complete_onboarding); - } - - #[test] - fn onboarding_click_continue_requests_completion() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap(); - let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - continue_rect.x, - continue_rect.y, - )); - - assert!(app.state.request_complete_onboarding); - } - - #[test] - fn release_notes_preview_scrollbar_uses_full_content_body() { - let mut app = app_for_mouse_test(); - app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16); - app.state.view.terminal_area = Rect::new(24, 0, 96, 16); - app.state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: "9.9.9".into(), - body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it." - .into(), - scroll: 0, - preview: true, - }); - app.state.update_install_command = "brew update && brew upgrade herdr".into(); - - let inner = app.state.release_notes_modal_inner().unwrap(); - let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let body = app.state.release_notes_body_rect().unwrap(); - - assert_eq!(body, expected_body); - - let metrics = app.state.release_notes_scroll_metrics().unwrap(); - assert_eq!(metrics.viewport_rows, body.height as usize); - assert!(metrics.max_offset_from_bottom > 0); - - let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap(); - assert_eq!(track.y, body.y); - assert!(matches!( - app.state - .release_notes_scrollbar_target_at(track.x, track.y), - Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. }) - )); - } -} diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs deleted file mode 100644 index a66509f0..00000000 --- a/src/app/input/selection.rs +++ /dev/null @@ -1,306 +0,0 @@ -use crossterm::event::{MouseEvent, MouseEventKind}; - -use crate::{ - app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, - terminal::TerminalRuntimeRegistry, -}; - -impl AppState { - pub(crate) fn update_selection_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - screen_col: u16, - screen_row: u16, - ) { - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - if let Some(selection) = self.selection.as_mut() { - selection.drag(screen_col, screen_row, info.inner_rect, metrics); - } - } - - fn selection_edge_scroll_lines(distance: u16) -> usize { - usize::from(distance).saturating_mul(3).clamp(3, 15) - } - - pub(super) fn update_selection_drag( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - screen_col: u16, - screen_row: u16, - ) { - let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - - let top = info.inner_rect.y; - let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); - - // Only activate autoscroll when the user is actively dragging. - // An anchored click in the hot zone should not start the timer. - // Check before advancing the cursor: if already Dragging from a prior - // event, it stays true. If Anchored, the mouse must have moved away - // from the anchor cell for this to count as a real drag. - let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging()); - let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| { - // Convert anchor to screen coords for comparison. - // Anchor is stored in absolute row; for a simple screen - // comparison, check whether the mouse is on a different - // cell than the anchor's screen position. - let (ar, ac) = s.anchor_screen_pos( - info.inner_rect, - self.pane_scroll_metrics(terminal_runtimes, s.pane_id), - ); - ar != screen_row || ac != screen_col - }); - let is_dragging = was_dragging || anchor_differs_from_mouse; - - // Advance the selection cursor. - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - - // If the mouse is on a different cell than the anchor but drag() - // didn't transition (cursor clamped to edge == anchor), force - // Dragging so the selection becomes visible and autoscroll can run. - if is_dragging { - if let Some(sel) = self.selection.as_mut() { - if sel.is_just_click() { - sel.force_dragging(); - } - } - } - - if screen_row < top { - // Cursor above pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_up( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(top - screen_row), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row > bottom { - // Cursor below pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_down( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(screen_row - bottom), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row == top { - // Hot zone: top edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else if screen_row == bottom { - // Hot zone: bottom edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else { - // Safe zone: inside pane, not on edge rows — clear autoscroll - self.selection_autoscroll = None; - } - } - - pub(super) fn scroll_selection_with_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) -> bool { - let lines_per_notch = self.mouse_scroll_lines; - - let Some(selection) = self.selection.as_ref() else { - return false; - }; - if !selection.is_in_progress() { - return false; - } - let pane_id = selection.pane_id; - self.focus_pane(pane_id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch) - } - _ => return false, - } - self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); - true - } -} - -#[cfg(test)] -mod autoscroll_tests { - use super::*; - use crate::layout::PaneInfo; - use crate::terminal::TerminalRuntimeRegistry; - use crate::workspace::Workspace; - use ratatui::layout::Rect; - - /// Build an AppState with one workspace/pane and pane_infos populated - /// so pane_info_by_id works. Returns (state, pane_id). - fn make_state_with_pane() -> (AppState, crate::layout::PaneId) { - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 0, 80, 24), - inner_rect: Rect::new(0, 0, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (state, pane_id) - } - - #[test] - fn above_pane_sets_autoscroll_up() { - // Build state with pane starting at row 5 so we can drag above it - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 5, 80, 24), - inner_rect: Rect::new(0, 5, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - // Anchor at (5, 10), drag to different cell above pane - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 4); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_sets_autoscroll_up_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (5, 10), drag to top edge row (row 0) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the top edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Same-cell drag on top edge row — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn bottom_hot_zone_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to bottom edge row (row 23) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn bottom_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the bottom edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at bottom edge row - state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); - // Same-cell drag — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn below_pane_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to different cell below pane - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Drag cursor one row below the pane bottom - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 24); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn safe_zone_clears_autoscroll() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to (5, 5) so it's truly dragging - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Set autoscroll first - state.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: Rect::new(0, 0, 80, 24), - }); - // Move cursor into safe zone (middle of pane, not on edge rows) - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 12); - assert!(state.selection_autoscroll.is_none()); - } -} diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs deleted file mode 100644 index fb1a7d75..00000000 --- a/src/app/input/settings.rs +++ /dev/null @@ -1,693 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{AppState, SettingsSection, THEME_NAMES}, - App, Mode, - }, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -// The shared `Save` verb is semantic: these actions persist settings. -#[allow(clippy::enum_variant_names)] -pub(super) enum SettingsAction { - SaveTheme(String), - SaveStatusIndicators(StatusIndicatorStyle), - SaveSound(bool), - SaveToastDelivery(ToastDelivery), - SaveAgentBorderLabels(bool), - InstallRecommendedIntegrations, -} - -impl App { - pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { - let previous_section = self.state.settings.section; - if let Some(action) = update_settings_state(&mut self.state, key) { - match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() - } - } - } - if previous_section != SettingsSection::Integrations - && self.state.settings.section == SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - } -} - -fn normalize_theme_name(name: &str) -> String { - name.to_lowercase().replace([' ', '_'], "-") -} - -fn current_theme_index(theme_name: &str) -> usize { - let normalized = normalize_theme_name(theme_name); - THEME_NAMES - .iter() - .position(|name| normalize_theme_name(name) == normalized) - .unwrap_or(0) -} - -fn status_indicator_index(style: StatusIndicatorStyle) -> usize { - match style { - StatusIndicatorStyle::Dots => 0, - StatusIndicatorStyle::Symbols => 1, - } -} - -fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle { - if idx == 0 { - StatusIndicatorStyle::Dots - } else { - StatusIndicatorStyle::Symbols - } -} - -fn toast_delivery_index(delivery: ToastDelivery) -> usize { - match delivery { - ToastDelivery::Off => 0, - ToastDelivery::Herdr => 1, - ToastDelivery::Terminal => 2, - ToastDelivery::System => 3, - } -} - -fn toast_delivery_for_index(idx: usize) -> ToastDelivery { - match idx { - 0 => ToastDelivery::Off, - 1 => ToastDelivery::Herdr, - 2 => ToastDelivery::Terminal, - _ => ToastDelivery::System, - } -} - -fn preview_selected_theme(state: &mut AppState) { - use crate::app::state::Palette; - - let name = THEME_NAMES[state.settings.list.selected]; - if let Some(mut palette) = Palette::from_name(name) { - if let Some(custom) = &state.theme_runtime.custom { - palette = palette.with_overrides(custom); - } - if let Some(accent) = &state.theme_runtime.legacy_accent { - palette.accent = crate::config::parse_color(accent); - } - state.palette = palette; - state.theme_name = name.to_string(); - } -} - -fn cancel_settings(state: &mut AppState) { - if let Some(palette) = state.settings.original_palette.take() { - state.palette = palette; - } - if let Some(theme_name) = state.settings.original_theme.take() { - state.theme_name = theme_name; - } - super::modal::leave_modal(state); -} - -fn integrations_need_install(state: &AppState) -> bool { - state - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) -} - -fn apply_settings(state: &mut AppState) -> Option { - match state.settings.section { - SettingsSection::Theme => { - let theme_name = state.theme_name.clone(); - state.settings.original_palette = None; - state.settings.original_theme = None; - super::modal::leave_modal(state); - Some(SettingsAction::SaveTheme(theme_name)) - } - SettingsSection::Integrations if integrations_need_install(state) => { - Some(SettingsAction::InstallRecommendedIntegrations) - } - SettingsSection::Integrations => None, - _ => { - super::modal::leave_modal(state); - None - } - } -} - -pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option { - match state.settings.section { - SettingsSection::Theme => match key.code { - KeyCode::Up | KeyCode::Char('k') => { - let previous = state.settings.list.selected; - state.settings.list.move_prev(); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Down | KeyCode::Char('j') => { - let previous = state.settings.list.selected; - state.settings.list.move_next(THEME_NAMES.len()); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - SettingsSection::Indicators => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let style = status_indicator_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveStatusIndicators(style)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Sound => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveSound(enabled)); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Toast => match key.code { - KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4), - KeyCode::Enter | KeyCode::Char(' ') => { - let delivery = toast_delivery_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveToastDelivery(delivery)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::PaneLabels => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveAgentBorderLabels(enabled)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Integrations => match key.code { - KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { - return Some(SettingsAction::InstallRecommendedIntegrations); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - } - - None -} - -pub(crate) fn open_settings(state: &mut AppState) { - open_settings_at(state, SettingsSection::Theme); -} - -pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { - state.integration_install_messages.clear(); - state.settings.original_palette = Some(state.palette.clone()); - state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = section; - state.settings.list.selected = match section { - SettingsSection::Theme => current_theme_index(&state.theme_name), - SettingsSection::Indicators => status_indicator_index(state.status_indicators), - SettingsSection::Sound => usize::from(!state.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), - SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), - SettingsSection::Integrations => 0, - }; - state.mode = Mode::Settings; -} - -impl AppState { - fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect( - self.screen_rect(), - crate::ui::SETTINGS_POPUP_WIDTH, - crate::ui::settings_popup_height(self), - ) - .unwrap_or_default() - } - - fn settings_inner_rect(&self) -> Rect { - let popup = self.settings_popup_rect(); - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - } - - fn settings_tab_at(&self, col: u16, row: u16) -> Option { - let inner = self.settings_inner_rect(); - let tab_y = inner.y + 1; - if row != tab_y { - return None; - } - let mut x = inner.x; - for section in SettingsSection::ALL { - let badge_width = if self.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - let width = section.label().len() as u16 + 2 + badge_width; - if col >= x && col < x + width { - return Some(*section); - } - x += width + 1; - } - None - } - - pub(crate) fn settings_content_rect(&self) -> Rect { - let inner = self.settings_inner_rect(); - crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content - } - - fn settings_list_index_at(&self, col: u16, row: u16) -> Option { - let area = self.settings_content_rect(); - if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width - { - return None; - } - - match self.settings.section { - SettingsSection::Theme => { - let max_visible = area.height as usize; - let scroll = if self.settings.list.selected >= max_visible { - self.settings.list.selected - max_visible + 1 - } else { - 0 - }; - let idx = scroll + (row - area.y) as usize; - (idx < THEME_NAMES.len()).then_some(idx) - } - SettingsSection::Indicators | SettingsSection::Sound => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Toast => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 8 { - Some(((row - list_y) / 2) as usize) - } else { - None - } - } - SettingsSection::PaneLabels => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Integrations => None, - } - } - - pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { - self.settings.section = section; - self.settings.list.select(match section { - SettingsSection::Theme => current_theme_index(&self.theme_name), - SettingsSection::Indicators => { - status_indicator_index(self.status_indicators) - } - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), - SettingsSection::PaneLabels => { - usize::from(!self.agent_border_labels_enabled()) - } - SettingsSection::Integrations => 0, - }); - return None; - } - if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { - self.settings.list.select(idx); - return match self.settings.section { - SettingsSection::Theme => { - preview_selected_theme(self); - None - } - SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators( - status_indicator_for_index(idx), - )), - SettingsSection::Sound => { - let enabled = idx == 0; - Some(SettingsAction::SaveSound(enabled)) - } - SettingsSection::Toast => { - let delivery = toast_delivery_for_index(idx); - Some(SettingsAction::SaveToastDelivery(delivery)) - } - SettingsSection::PaneLabels => { - let enabled = idx == 0; - Some(SettingsAction::SaveAgentBorderLabels(enabled)) - } - SettingsSection::Integrations => None, - }; - } - - let inner = self.settings_inner_rect(); - let show_primary = crate::ui::settings_show_primary_action(self); - let (apply, close) = - crate::ui::settings_button_rects(inner, self.settings.section, show_primary); - let mut buttons = vec![(close, super::modal::ModalAction::Close)]; - if let Some(apply) = apply { - buttons.insert(0, (apply, super::modal::ModalAction::Apply)); - } - match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) { - Some(super::modal::ModalAction::Apply) => apply_settings(self), - Some(super::modal::ModalAction::Close) => { - cancel_settings(self); - None - } - _ => { - cancel_settings(self); - None - } - } - } - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; - - use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; - use super::*; - - #[test] - fn settings_cancel_restores_previewed_theme_from_other_sections() { - let mut state = state_with_workspaces(&["test"]); - let original_palette = state.palette.clone(); - let original_theme = state.theme_name.clone(); - - open_settings(&mut state); - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - assert_ne!(state.theme_name, original_theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!( - state.settings.section, - crate::app::state::SettingsSection::Indicators - ); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.theme_name, original_theme); - assert_eq!(state.palette.accent, original_palette.accent); - assert_eq!(state.palette.panel_bg, original_palette.panel_bg); - } - - #[test] - fn settings_indicator_choice_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Indicators); - state.settings.list.selected = 1; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!( - action, - Some(SettingsAction::SaveStatusIndicators( - StatusIndicatorStyle::Symbols - )) - ); - assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_sound_toggle_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings(&mut state); - state.settings.section = crate::app::state::SettingsSection::Sound; - state.settings.list.selected = 0; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(!state.sound.enabled); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_tab_cycle_wraps_after_integrations() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::PaneLabels); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::PaneLabels); - } - - #[test] - fn integrations_enter_does_nothing_when_nothing_needs_install() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Integrations); - - let enter_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(enter_action, None); - - let space_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), - ); - assert_eq!(space_action, None); - } - - #[test] - fn settings_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - open_settings(&mut app.state); - app.state.settings.list.select(0); - - let area = app.state.settings_content_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); - - assert_eq!(app.state.settings.list.selected, 0); - } - - #[test] - fn integration_update_badge_only_tracks_outdated_recommendations() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - false, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Current, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - assert!(state.integration_updates_available()); - } - - #[test] - fn settings_tab_hit_area_includes_integration_update_badge() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - open_settings(&mut state); - - let inner = state.settings_inner_rect(); - let tab_y = inner.y + 1; - let integrations_idx = SettingsSection::ALL - .iter() - .position(|section| *section == SettingsSection::Integrations) - .expect("integrations section should be present"); - let integrations_x = inner.x - + SettingsSection::ALL[..integrations_idx] - .iter() - .map(|section| { - let badge_width = if state.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - section.label().len() as u16 + 3 + badge_width - }) - .sum::(); - let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4; - - assert_eq!( - state.settings_tab_at(integrations_x + dotted_width - 1, tab_y), - Some(SettingsSection::Integrations) - ); - } - - fn integration_recommendation( - state: crate::integration::IntegrationStatusKind, - available: bool, - ) -> crate::integration::IntegrationRecommendation { - crate::integration::IntegrationRecommendation { - target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", - available, - path: std::path::PathBuf::from("/tmp/herdr-test-integration"), - state, - } - } -} diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs deleted file mode 100644 index 0923eef6..00000000 --- a/src/app/input/sidebar.rs +++ /dev/null @@ -1,1936 +0,0 @@ -use ratatui::layout::Rect; - -use crate::app::state::{AppState, ViewLayout}; - -use super::ScrollbarClickTarget; - -impl AppState { - pub(super) fn workspace_list_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) - } - - pub(super) fn agent_panel_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - let (_, detail_area) = - crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); - detail_area - } - - pub(super) fn workspace_list_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn workspace_list_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn scroll_workspace_list(&mut self, delta: i16) { - if delta.is_negative() { - self.workspace_scroll = self - .workspace_scroll - .saturating_sub(delta.unsigned_abs() as usize); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - return; - } - - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = self - .workspace_scroll - .saturating_add(delta as usize) - .min(metrics.max_offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn agent_panel_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn agent_panel_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - self.agent_panel_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - } - - pub(super) fn scroll_agent_panel(&mut self, delta: i16) { - let area = self.agent_panel_rect(); - let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; - if delta.is_negative() { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_sub(delta.unsigned_abs() as usize); - } else { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_add(delta as usize) - .min(max_scroll); - } - } - - pub(crate) fn sidebar_footer_rect(&self) -> Rect { - let ws_area = self.workspace_list_rect(); - if ws_area == Rect::default() { - return Rect::default(); - } - let y = ws_area.y + ws_area.height.saturating_sub(1); - Rect::new(ws_area.x, y, ws_area.width, 1) - } - - pub(crate) fn sidebar_new_button_rect(&self) -> Rect { - let footer = self.sidebar_footer_rect(); - let width = 5u16.min(footer.width.max(1)); - Rect::new(footer.x, footer.y, width, footer.height) - } - - pub(crate) fn global_launcher_rect(&self) -> Rect { - if self.view.layout == ViewLayout::Mobile { - return self.view.mobile_menu_hit_area; - } - - let footer = self.sidebar_footer_rect(); - let width = if self.global_menu_attention_badge_visible() { - 8 - } else { - 6 - } - .min(footer.width.max(1)); - let x = footer.x + footer.width.saturating_sub(width); - Rect::new(x, footer.y, width, footer.height) - } - - pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload config"]; - if self.update_available.is_some() { - labels.push("update ready"); - } else if self.latest_release_notes_available { - labels.push("what's new"); - } - labels.push("detach"); - labels - } - - pub(crate) fn global_menu_rect(&self) -> Rect { - let screen = self.screen_rect(); - let launcher = self.global_launcher_rect(); - let labels = self.global_menu_labels(); - let content_width = labels - .iter() - .map(|label| { - let badge_width = if self.global_menu_item_has_badge(label) { - 2 - } else { - 0 - }; - label.chars().count() as u16 + badge_width - }) - .max() - .unwrap_or(8) - .saturating_add(2); - let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); - let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); - let max_x = screen.x + screen.width.saturating_sub(menu_w); - let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); - let x = desired_x.min(max_x); - let y = launcher.y.saturating_sub(menu_h); - Rect::new(x, y, menu_w, menu_h) - } - - pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let sidebar = self.view.sidebar_rect; - let toggle = crate::ui::expanded_sidebar_toggle_rect(sidebar); - let on_toggle = toggle.width > 0 - && col >= toggle.x - && col < toggle.x + toggle.width - && row >= toggle.y - && row < toggle.y + toggle.height; - sidebar.width > 0 - && !on_toggle - && col == sidebar.x + sidebar.width.saturating_sub(1) - && row >= sidebar.y - && row < sidebar.y + sidebar.height - } - - pub(super) fn on_sidebar_toggle(&self, col: u16, row: u16) -> bool { - let rect = if self.sidebar_collapsed { - crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect) - } else { - crate::ui::expanded_sidebar_toggle_rect(self.view.sidebar_rect) - }; - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) { - let sidebar = self.view.sidebar_rect; - let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); - self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width); - self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual; - self.mark_session_dirty(); - } - - pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let rect = crate::ui::sidebar_section_divider_rect( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_sidebar_section_split(&mut self, row: u16) { - let sidebar = self.view.sidebar_rect; - let content_height = sidebar.height; - if content_height < 6 { - return; - } - let relative_y = row.saturating_sub(sidebar.y); - let ratio = (relative_y as f32) / (content_height as f32); - self.sidebar_section_split = ratio.clamp(0.1, 0.9); - self.mark_session_dirty(); - } - - pub(super) fn workspace_at_row(&self, row: u16) -> Option { - let footer = self.sidebar_footer_rect(); - if footer == Rect::default() { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - - cards.iter().find_map(|card| { - (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) - }) - } - - pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option { - if !self.sidebar_collapsed { - return None; - } - - let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { - return None; - } - - let idx = (row - ws_area.y) as usize; - (idx < self.workspaces.len()).then_some(idx) - } - - pub(super) fn collapsed_agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if !self.sidebar_collapsed { - return None; - } - - let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area == Rect::default() - || row < detail_content_area.y - || row >= detail_content_area.y + detail_content_area.height - { - return None; - } - - let detail_idx = (row - detail_content_area.y) as usize; - let details = crate::ui::agent_panel_entries(self); - let detail = details.get(detail_idx)?; - Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) - } - - pub(super) fn workspace_drop_target_at_row( - &self, - row: u16, - ) -> Option { - let area = self.workspace_list_rect(); - let footer = self.sidebar_footer_rect(); - if area == Rect::default() || row < area.y || row >= footer.y { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - crate::ui::workspace_drop_slots(self, &cards, area) - .into_iter() - .enumerate() - .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx)) - .map(|(_, (target, _))| target) - } - - pub(super) fn workspace_move_block_params( - &self, - source_ws_idx: usize, - drop_target: crate::app::state::WorkspaceDropTarget, - ) -> Option { - let source = self.workspaces.get(source_ws_idx)?; - if source - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return None; - } - - let roots = crate::ui::workspace_list_entries_expanded(self) - .into_iter() - .filter_map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(ws_idx), - crate::ui::WorkspaceListEntry::Workspace { .. } => None, - }) - .collect::>(); - let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?; - let remaining_roots = roots - .iter() - .copied() - .filter(|ws_idx| *ws_idx != source_ws_idx) - .collect::>(); - let insert_pos = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots - .iter() - .position(|ws_idx| *ws_idx == target_ws_idx)?, - crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(), - }; - if insert_pos == source_pos { - return None; - } - - let workspace_ids = match source.worktree_space() { - Some(source_space) => { - let mut ids = vec![source.id.clone()]; - ids.extend( - self.workspaces - .iter() - .filter(|workspace| workspace.id != source.id) - .filter(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == source_space.key) - }) - .map(|workspace| workspace.id.clone()), - ); - ids - } - None => vec![source.id.clone()], - }; - let before_workspace_id = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => { - let target = self.workspaces.get(target_ws_idx)?; - let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx) - .and_then(|_| target.worktree_space()) - { - Some(target_space) => self - .workspaces - .iter() - .find(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == target_space.key) - }) - .unwrap_or(target), - None => target, - }; - Some(anchor.id.clone()) - } - crate::app::state::WorkspaceDropTarget::End => None, - }; - - Some(crate::api::schema::WorkspaceMoveBlockParams { - workspace_ids, - before_workspace_id, - }) - } - - pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed || self.agent_view_override.is_some() { - return false; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if self.sidebar_collapsed { - return None; - } - - let detail_area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - if body.height == 0 || row < body.y || row >= body.y + body.height { - return None; - } - - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let entries = crate::ui::agent_panel_entries(self); - let scroll = self.agent_panel_scroll.min(metrics.max_offset_from_bottom); - for (index, detail) in entries.iter().enumerate().skip(scroll) { - let height = crate::ui::agent_entry_height_in_body(self, detail, body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - if row >= row_y && row < row_y.saturating_add(height) { - return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); - } - row_y = row_y - .saturating_add(height) - .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len())) - .min(body_bottom); - } - None - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; - use crate::{ - app::state::{AgentPanelSort, DragTarget, Mode}, - config::SidebarCollapsedModeConfig, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn clicking_launcher_opens_global_menu() { - let mut app = app_for_mouse_test(); - let rect = app.state.global_launcher_rect(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - rect.x + rect.width.saturating_sub(1), - rect.y, - )); - - assert_eq!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn hovering_global_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.global_menu.highlighted, 1); - } - - #[test] - fn clicking_keybinds_menu_item_opens_help() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn clicking_settings_menu_item_opens_settings() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Settings); - } - - #[test] - fn clicking_reload_config_menu_item_requests_reload() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert!(app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn update_pending_menu_surfaces_update_ready_entry() { - let mut app = app_for_mouse_test(); - app.state.update_available = Some("0.3.2".into()); - app.state.latest_release_notes_available = true; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "update ready", - "detach" - ] - ); - assert!(!app.state.should_quit); - } - - #[test] - fn persistence_mode_menu_surfaces_detach_action() { - let mut app = app_for_mouse_test(); - app.state.detach_exits = false; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec!["settings", "keybinds", "reload config", "detach"] - ); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 4, - )); - - assert!(app.state.detach_requested); - assert!(!app.state.should_quit); - assert_ne!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn whats_new_remains_in_menu_for_latest_installed_release_notes() { - let mut app = app_for_mouse_test(); - app.state.latest_release_notes_available = true; - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "what's new", - "detach" - ] - ); - } - - #[test] - fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let first_pane = ws.tabs[0].root_pane; - let first_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[first_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, first_tab); - assert_eq!( - snapshot.workspaces[0].tabs[first_tab].focused, - Some(second_pane.raw()) - ); - } - - #[test] - fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - for (ws_idx, pane_id, agent) in - [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] - { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.sidebar_agents.row_gap = 1; - let detail_area = app.state.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); - assert_eq!( - app.state.agent_detail_target_at(body.y + 3), - Some((1, 0, second_pane)) - ); - - app.state.sidebar_agents.row_gap = 0; - assert_eq!( - app.state.agent_detail_target_at(body.y + 1), - Some((1, 0, second_pane)) - ); - } - - #[test] - fn agent_hit_testing_clamps_scroll_after_dynamic_filter_shrink() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - for (ws_idx, pane_id) in [(0, first_pane), (1, second_pane)] { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::WorkspaceId, - ), - value: crate::api::schema::AgentViewValue::Context { - context: crate::api::schema::AgentViewContext::CurrentWorkspaceId, - }, - }), - sort: Vec::new(), - }); - app.state.agent_panel_scroll = 10; - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, false); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - } - - #[test] - fn clicking_agent_panel_toggle_switches_sort() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scroll = 3; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority); - assert_eq!(app.state.agent_panel_scroll, 0); - } - - #[test] - fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x + 2, - detail_area.y + 6, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.workspaces[1].active_tab, 0); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - - let mut tabs = Vec::new(); - for (tab_name, agent) in [ - ("logs", Agent::Claude), - ("review", Agent::Codex), - ("ops", Agent::Gemini), - ] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - for (tab_idx, pane_id, agent) in tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let detail_area = app.state.agent_panel_rect(); - assert!(crate::ui::should_show_scrollbar( - crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) - )); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - detail_area.x + 1, - detail_area.y + 4, - )); - - assert_eq!(app.state.agent_panel_scroll, 1); - assert_eq!(app.state.selected, 0); - } - - #[test] - fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - let mut extra_tabs = Vec::new(); - for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - extra_tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - for (tab_idx, pane_id, agent) in extra_tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.agent_panel_scroll = 1; - - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, true); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - body.x + 1, - body.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, second_tab); - assert_eq!( - app.state.workspaces[0].tabs[second_tab].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_priority_agent_row_switches_to_matching_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.agent_panel_sort = AgentPanelSort::Priority; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Working); - set_state(&mut app, 1, second_pane, AgentState::Blocked); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn clicking_collapsed_sidebar_toggle_expands_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(!app.state.sidebar_collapsed); - } - - #[test] - fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden; - app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20); - app.state.view.terminal_area = Rect::new(0, 0, 80, 20); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19)); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn clicking_expanded_sidebar_toggle_collapses_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = false; - app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = Rect::new(26, 0, 80, 20); - - let toggle = crate::ui::expanded_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(app.state.sidebar_collapsed); - assert!(app.state.drag.is_none()); - } - - #[test] - fn clicking_workspace_switches_on_mouse_up() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let target_row = app.state.view.workspace_card_areas[1].rect.y; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - target_row, - )); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspace_presses.len(), 1); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.workspace_presses.is_empty()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.active, Some(1)); - assert_eq!(snapshot.selected, 1); - } - - #[test] - fn clicking_worktree_parent_row_focuses_workspace_without_toggling() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0].rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - parent.x + 2, - parent.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - parent.x + 2, - parent.y, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn clicking_worktree_parent_chevron_toggles_group_only() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0]; - let chevron = crate::ui::workspace_group_chevron_rect(&parent); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert_eq!(app.state.active, None); - assert!(app.state.workspace_presses.is_empty()); - assert!(app.state.collapsed_space_keys.contains("repo-key")); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn wheel_workspace_selection_follows_grouped_visual_order_without_scrollbar() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("main"), - Workspace::test_new("normal"), - Workspace::test_new("issue"), - ]; - for (idx, checkout_path) in [(0, "/repo/herdr"), (2, "/repo/herdr-issue")] { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx != 0, - }); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 30)); - let list = app.state.workspace_list_rect(); - assert!(!crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(&app.state, list) - )); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, list.x + 1, list.y + 1)); - - assert_eq!(app.state.selected, 2); - } - - #[test] - fn dragging_workspace_reorders_without_changing_identity() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.state.sidebar_spaces.row_gap = 0; - let active_id = app.state.workspaces[1].id.clone(); - let selected_id = app.state.workspaces[2].id.clone(); - app.state.active = Some(1); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y; - assert_eq!( - app.state.workspace_drop_target_at_row(packed_boundary_row), - Some(crate::app::state::WorkspaceDropTarget::Before(2)) - ); - - let source_row = app.state.view.workspace_card_areas[1].rect.y; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - source_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 1, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names: Vec<_> = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect(); - assert_eq!(names, vec!["b", "a", "c"]); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.workspaces[0].id, active_id); - assert_eq!(app.state.workspaces[2].id, selected_id); - let events = app.event_hub.events_after(0); - assert!(events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceMoved { .. } - ))); - assert!(!events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceReordered { .. } - ))); - let snapshot = capture_snapshot(&app.state); - let captured_names: Vec<_> = snapshot - .workspaces - .iter() - .map(|ws| ws.custom_name.clone().unwrap()) - .collect(); - assert_eq!(captured_names, vec!["b", "a", "c"]); - } - - #[test] - fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - ws.test_add_tab(Some("review")); - ws.test_add_tab(Some("ops")); - ws.test_add_tab(Some("notes")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let right = app.state.view.tab_scroll_right_hit_area; - assert!(right.width > 0); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - right.x + 1, - right.y, - )); - - assert_eq!(app.state.tab_scroll, 1); - assert!(!app.state.tab_scroll_follow_active); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.view.tab_hit_areas[0].width, 0); - assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); - assert_eq!( - app.state.workspaces[0].tabs[1].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.tab_scroll = usize::MAX; - app.state.tab_scroll_follow_active = false; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let last_idx = app.state.workspaces[0].tabs.len() - 1; - let target = app.state.view.tab_hit_areas[last_idx]; - let clamped_scroll = app.state.tab_scroll; - assert!(target.width > 0, "last tab should already be visible"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - target.x + 1, - target.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - target.x + 1, - target.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, last_idx); - assert_eq!(app.state.tab_scroll, clamped_scroll); - assert!(app.state.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("foo")); - ws.test_add_tab(None); - let moved_root = ws.tabs[0].root_pane; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let last = app.state.view.tab_hit_areas[2]; - let drop_col = last.x + last.width; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - source.y, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { - ws_idx: 0, - source_tab_idx: 0, - insert_idx: Some(3), - .. - }) - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - drop_col, - source.y, - )); - - let labels: Vec<_> = app.state.workspaces[0] - .tabs - .iter() - .enumerate() - .map(|(tab_idx, _)| app.state.workspaces[0].tab_display_name(tab_idx).unwrap()) - .collect(); - assert_eq!(labels, vec!["foo", "2", "3"]); - assert_eq!( - app.state.workspaces[0].tabs[0].custom_name.as_deref(), - Some("foo") - ); - assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); - assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].number, 2); - assert_eq!(app.state.workspaces[0].tabs[1].number, 3); - assert_eq!(app.state.workspaces[0].tabs[2].number, 1); - assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - fn temp_git_repo(branch: &str) -> std::path::PathBuf { - let repo = unique_temp_path("sidebar-drop-slot-repo"); - fs::create_dir_all(repo.join(".git")).unwrap(); - fs::write( - repo.join(".git/HEAD"), - format!("ref: refs/heads/{branch}\n"), - ) - .unwrap(); - repo - } - - fn workspace_with_space(name: &str, key: &str) -> Workspace { - let mut ws = Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/{name}").into(), - is_linked_worktree: name != "main", - }); - ws - } - - #[test] - fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { - let mut app = app_for_mouse_test(); - let first_repo = temp_git_repo("main"); - let second_repo = temp_git_repo("main"); - - let mut first = Workspace::test_new("a"); - let first_root = first.tabs[0].root_pane; - first.identity_cwd = first_repo.clone(); - first.refresh_git_ahead_behind(); - - let mut second = Workspace::test_new("b"); - let second_root = second.tabs[0].root_pane; - second.identity_cwd = second_repo.clone(); - second.refresh_git_ahead_behind(); - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone(); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .cwd = second_repo.clone(); - app.state.sidebar_spaces.row_gap = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - assert_eq!( - app.state.workspace_drop_target_at_row(0), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(1), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(2), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(3), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - - let _ = fs::remove_dir_all(first_repo); - let _ = fs::remove_dir_all(second_repo); - } - - #[test] - fn bottom_drop_slot_stays_below_last_workspace_not_footer() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - - let cards = &app.state.view.workspace_card_areas; - let bottom_slot = crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - - let last = cards.last().unwrap().rect; - assert_eq!(bottom_slot, last.y + last.height); - assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); - } - - #[test] - fn grouped_sidebar_drop_slots_do_not_land_inside_compact_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(1); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let cards = &app.state.view.workspace_card_areas; - let order = cards.iter().map(|card| card.ws_idx).collect::>(); - assert_eq!(order, vec![0, 2, 1]); - let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap(); - let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap(); - - assert_eq!( - app.state.workspace_drop_target_at_row(issue.rect.y), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - assert_eq!( - crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ), - Some(normal.rect.y + normal.rect.height) - ); - } - - #[test] - fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("one", "repo-key"), - workspace_with_space("two", "repo-key"), - Workspace::test_new("normal"), - ]; - let target_id = app.state.workspaces[1].id.clone(); - - let params = app - .state - .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1)) - .unwrap(); - - assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]); - assert_eq!( - params.before_workspace_id.as_deref(), - Some(target_id.as_str()) - ); - } - - #[test] - fn dragging_worktree_parent_reorders_the_complete_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(2); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let parent = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 0) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - let active_id = app.state.workspaces[2].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::End), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::>(), - ["normal", "main", "issue"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_collapsed_worktree_parent_still_moves_hidden_children() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("issue", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("main", "repo-key"), - workspace_with_space("review", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.collapsed_space_keys.insert("repo-key".into()); - let active_id = app.state.workspaces[0].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - assert_eq!(app.state.view.workspace_card_areas.len(), 3); - - let parent = app.state.view.workspace_card_areas[0].rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::>(), - ["normal", "main", "issue", "review"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_worktree_space_member_does_not_reorder_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let source = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 2) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, source.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(app.state.drag.is_none()); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect::>(); - assert_eq!(names, vec!["main", "normal", "issue"]); - } - - #[test] - fn dragging_sidebar_divider_sets_manual_width() { - let mut app = app_for_mouse_test(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); - - assert_eq!(app.state.sidebar_width, 31); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(31)); - } - - #[test] - fn dragging_sidebar_bottom_divider_still_sets_manual_width() { - let mut app = app_for_mouse_test(); - let divider_col = app.state.view.sidebar_rect.x + app.state.view.sidebar_rect.width - 1; - let bottom_row = app.state.view.sidebar_rect.y + app.state.view.sidebar_rect.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider_col, - bottom_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider_col + 5, - bottom_row, - )); - - assert_eq!(app.state.sidebar_width, 31); - } - - #[test] - fn dragging_past_max_clamps_to_configured_max() { - let mut app = app_for_mouse_test(); - app.state.sidebar_max_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5)); - - assert_eq!(app.state.sidebar_width, 30); - } - - #[test] - fn dragging_below_min_clamps_to_configured_min() { - let mut app = app_for_mouse_test(); - app.state.sidebar_min_width = 22; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5)); - - assert_eq!(app.state.sidebar_width, 22); - } - - #[test] - fn dragging_sidebar_section_divider_sets_split_ratio() { - let mut app = app_for_mouse_test(); - let divider = crate::ui::sidebar_section_divider_rect( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider.x + 1, - divider.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider.x + 1, - divider.y + 4, - )); - - assert!(app.state.sidebar_section_split > 0.5); - let snapshot = capture_snapshot(&app.state); - assert_eq!( - snapshot.sidebar_section_split, - Some(app.state.sidebar_section_split) - ); - } - - #[test] - fn double_clicking_sidebar_divider_resets_default_width() { - let mut app = app_for_mouse_test(); - app.state.default_sidebar_width = 26; - app.state.sidebar_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - - assert_eq!(app.state.sidebar_width, 26); - assert!(app.state.drag.is_none()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(26)); - } -} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs deleted file mode 100644 index 0b910621..00000000 --- a/src/app/input/terminal.rs +++ /dev/null @@ -1,2181 +0,0 @@ -use bytes::Bytes; -use crossterm::event::KeyCode; -use tracing::{debug, warn}; - -use crate::{ - app::{App, InputSourceId, Mode, TerminalInputTarget}, - input::TerminalKey, -}; - -struct PreparedPaneInput { - ws_idx: usize, - pane_id: crate::layout::PaneId, - target: TerminalInputTarget, - bytes: Bytes, -} - -enum PreparedPopupInput { - NotOpen, - Consumed, - Bytes { - target: TerminalInputTarget, - bytes: Bytes, - }, -} - -fn is_modifier_only_key(code: &KeyCode) -> bool { - matches!(code, KeyCode::Modifier(_)) -} - -impl App { - #[cfg(test)] - pub(crate) fn handle_terminal_key_headless( - &mut self, - key: TerminalKey, - ) -> Option { - self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key) - } - - pub(crate) fn handle_terminal_key_headless_from( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option { - match self.prepare_popup_key_forward(key.clone()) { - PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return None, - PreparedPopupInput::Bytes { target, bytes } => { - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return None; - }; - return runtime.try_send_bytes(bytes).is_ok().then_some(target); - } - } - - let input = self.prepare_terminal_key_forward(source_id, key)?; - let sent = self - .lookup_runtime_sender(input.ws_idx, input.pane_id) - .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok()); - sent.then_some(input.target) - } - - fn prepare_terminal_key_forward( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option { - let key_event = key.as_key_event(); - if self.try_copy_retained_selection(source_id, key.clone()) { - return None; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - - if let Some(action) = - super::terminal_direct_non_indexed_navigation_action(&self.state, &key) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct keybinding before forwarding to pane" - ); - if action == super::navigate::NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - } - return None; - } - - if let Some(binding) = super::navigate::command_for_key( - &self.state, - &key, - super::navigate::BindingDispatch::Direct, - ) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - command = %binding.label, - "intercepted terminal direct custom command before forwarding to pane" - ); - self.launch_custom_command(binding, super::navigate::ActionContext::Direct); - return None; - } - - if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct indexed keybinding before forwarding to pane" - ); - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - return None; - } - - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return None; - } - - if is_modifier_only_key(&key_event.code) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - "dropping modifier-only terminal key event instead of forwarding it to pane" - ); - return None; - } - - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let pane_id = ws.focused_pane_id()?; - let terminal_id = ws.terminal_id(pane_id)?.clone(); - let rt = - self.state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; - - // Intercept plain PageUp/PageDown presses for pane scrollback only - // when the focused pane looks like a shell transcript. Normal-screen - // pagers such as `less -X` keep the primary screen but enter - // application cursor mode while they own special keys. - // Modified page keys are pane shortcuts, and release events should not - // produce a second host-scroll action. - // Only intercept when we know the pane state; if input_state is unknown, - // fail-open and forward the key to the pane. - if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) - && key_event.modifiers.is_empty() - { - if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() { - if host_scroll { - if key_event.kind == crossterm::event::KeyEventKind::Release { - return None; - } - if matches!( - key_event.kind, - crossterm::event::KeyEventKind::Press - | crossterm::event::KeyEventKind::Repeat - ) { - let lines = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height as usize) - .unwrap_or(10) - .max(1); - if key_event.code == KeyCode::PageUp { - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); - } else { - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); - } - debug!( - code = ?key_event.code, - lines, - "intercepted page key for pane scrollback" - ); - return None; - } - } - } - } - - rt.scroll_reset(); - let protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key.clone()); - - if matches!(key_event.code, KeyCode::Esc) - || key_event - .modifiers - .contains(crossterm::event::KeyModifiers::ALT) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - protocol = ?protocol, - encoded = ?bytes, - "forwarding potentially-ambiguous terminal key to pane" - ); - } - - if bytes.is_empty() { - if key.kind != crossterm::event::KeyEventKind::Release - && !matches!( - key.code, - KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) - ) - { - warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); - } - return None; - } - - Some(PreparedPaneInput { - ws_idx, - pane_id, - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - }) - } - - fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput { - if self.state.popup_pane.is_none() { - return PreparedPopupInput::NotOpen; - } - let Some(terminal_id) = self - .state - .popup_pane - .as_ref() - .map(|popup| popup.terminal_id.clone()) - else { - return PreparedPopupInput::NotOpen; - }; - let Some(rt) = self.terminal_runtimes.get(&terminal_id) else { - self.close_popup_pane(); - return PreparedPopupInput::Consumed; - }; - rt.scroll_reset(); - let bytes = rt.encode_terminal_key(key.clone()); - self.state.mode = Mode::Terminal; - if bytes.is_empty() { - PreparedPopupInput::Consumed - } else { - PreparedPopupInput::Bytes { - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - } - } - } - - pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { - if self.state.popup_pane.is_none() - && matches!(self.state.mode, Mode::Prefix | Mode::Navigate) - { - return true; - } - - let runtime = if self.state.popup_pane.is_some() { - self.popup_runtime() - } else if self.state.mode == Mode::Terminal { - self.state.active.and_then(|ws_idx| { - self.state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - }) - } else { - None - }; - - runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested) - } - - fn terminal_input_runtime( - &self, - target: &TerminalInputTarget, - ) -> Option<&crate::terminal::TerminalRuntime> { - if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) { - return Some(runtime); - } - #[cfg(test)] - for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { - for tab in &workspace.tabs { - for (&pane_id, pane) in &tab.panes { - if pane.attached_terminal_id == target.terminal_id { - return self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - pane_id, - ); - } - } - } - } - None - } - - pub(crate) fn forward_terminal_key_to_target_headless( - &self, - target: &TerminalInputTarget, - key: TerminalKey, - ) -> bool { - let Some(runtime) = self.terminal_input_runtime(target) else { - return false; - }; - let bytes = runtime.encode_terminal_key(key.clone()); - bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() - } - - pub(crate) async fn forward_terminal_key_to_target( - &self, - target: &TerminalInputTarget, - key: TerminalKey, - ) -> bool { - let Some(runtime) = self.terminal_input_runtime(target) else { - return false; - }; - let bytes = runtime.encode_terminal_key(key.clone()); - bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok() - } - - fn take_pressed_keys_for_source( - &mut self, - source_id: crate::app::InputSourceId, - ) -> Vec { - self.input_leases.remove_source(source_id) - } - - pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) { - for pressed in self.input_leases.remove_target(target) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { - // Pending URL clicks survive this call; see clear_input_source. - self.state.clear_chrome_gesture(source_id); - for pressed in self.take_pressed_keys_for_source(source_id) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) { - // Pending URL clicks survive this call; see clear_input_source. - self.state.clear_chrome_gesture(source_id); - for pressed in self.take_pressed_keys_for_source(source_id) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self - .forward_terminal_key_to_target(&pressed.target, release) - .await; - } - } - - pub(super) async fn handle_terminal_key( - &mut self, - key: TerminalKey, - ) -> Option { - match self.prepare_popup_key_forward(key.clone()) { - PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return None, - PreparedPopupInput::Bytes { target, bytes } => { - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return None; - }; - return runtime.send_bytes(bytes).await.is_ok().then_some(target); - } - } - - let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?; - let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { - runtime.send_bytes(input.bytes).await.is_ok() - } else { - false - }; - sent.then_some(input.target) - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - #[cfg(target_os = "linux")] - use super::super::wait_for_detached_process_reap; - use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; - #[cfg(unix)] - use super::super::{unique_temp_path, wait_for_file}; - use super::*; - use crate::{config::Config, events::AppEvent, workspace::Workspace}; - - #[cfg(unix)] - fn app_with_spawned_workspace() -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.default_shell = "/bin/sh".into(); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app - } - - #[cfg(unix)] - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, info) - } - - fn double_click(app: &mut App, col: u16, row: u16) { - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - } - - fn modified_mouse( - kind: MouseEventKind, - col: u16, - row: u16, - modifiers: KeyModifiers, - ) -> crossterm::event::MouseEvent { - crossterm::event::MouseEvent { - kind, - column: col, - row, - modifiers, - } - } - - fn clipboard_write_content(app: &mut App) -> Vec { - match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, - event => panic!("unexpected event: {event:?}"), - } - } - - fn assert_visible_selection(app: &App) { - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible)); - } - - #[cfg(unix)] - fn install_test_link_handler(app: &mut App) { - let plugin_root = std::env::temp_dir(); - app.state.installed_plugins = std::collections::HashMap::from([( - "example.links".to_string(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.links".into(), - name: "Links".into(), - version: "0.1.0".into(), - min_herdr_version: "0.6.10".into(), - description: None, - manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: vec![crate::api::schema::PluginManifestAction { - id: "open".into(), - title: "Open link".into(), - description: None, - contexts: Vec::new(), - platforms: None, - command: vec!["sh".into(), "-c".into(), ":".into()], - }], - events: Vec::new(), - panes: Vec::new(), - link_handlers: vec![crate::api::schema::PluginManifestLinkHandler { - id: "github-issue".into(), - title: "Open GitHub issue".into(), - pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(), - action: "open".into(), - platforms: None, - }], - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - )]); - } - - #[tokio::test] - async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let start_row = info.inner_rect.y; - let start_col = info.inner_rect.x + 2; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - start_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - start_col, - info.inner_rect.y.saturating_sub(1), - )); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after drag"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after drag"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn releasing_dragged_selection_clears_highlight_after_copy() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + 1; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.state.selection.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn drag_copy_then_click_does_not_reuse_double_click_candidate() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.last_pane_click.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - - assert!(app.last_pane_click.is_some()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn double_click_selects_and_copies_word() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega"); - let col = info.inner_rect.x + 13; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn copy_on_select_disabled_keeps_drag_selection_without_copying() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert_visible_selection(&app); - assert!(!app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert_eq!( - app.state - .selection - .as_ref() - .map(crate::selection::Selection::ordered_cells), - Some(((0, 0), (0, 4))) - ); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - - assert_visible_selection(&app); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn new_drag_cancels_stale_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let word_col = info.inner_rect.x + 2; - - double_click(&mut app, word_col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let stale_deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.copy_on_select = false; - - let start_col = info.inner_rect.x + 6; - let end_col = info.inner_rect.x + 9; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.selection_highlight_clear_deadline.is_none()); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert!(!app - .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1))); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ignored_left_down_keeps_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "finished".into(), - context: "missing".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: "missing".into(), - pane_id: info.id, - }), - }); - app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0)); - - assert_visible_selection(&app); - assert_eq!(app.selection_highlight_clear_deadline, Some(deadline)); - assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1))); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn double_click_uses_display_columns_for_wide_chars() { - let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes()); - let col = info.inner_rect.x + 8; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes()); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_copies_quoted_path_without_quotes() { - let line = r#"cat "/tmp/build output/log.txt""#; - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("output").expect("path segment") as u16; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!( - clipboard_write_content(&mut app), - b"/tmp/build output/log.txt" - ); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_excludes_trailing_punctuation() { - let (mut app, info) = app_with_screen_bytes(b"done."); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"done"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn modified_pane_click_does_not_seed_double_click_copy() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 7; - let row = info.inner_rect.y; - - app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - row, - KeyModifiers::CONTROL, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.selection_highlight_clear_deadline.is_none()); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn ctrl_click_url_reaps_failed_opener() { - let opener_dir = unique_temp_path("url-opener"); - let record_path = opener_dir.join("record"); - let opener_path = opener_dir.join("xdg-open"); - std::fs::create_dir_all(&opener_dir).expect("fake opener directory"); - std::fs::write( - &opener_path, - "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$1\" > \"$2\"\nexit 3\n", - ) - .expect("fake opener script"); - - let url = "https://example.com/akbash-2903"; - let line = format!("see {url}"); - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("example").expect("url host") as u16; - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |clicked_url| { - std::process::Command::new("/bin/sh") - .arg(&opener_path) - .arg(clicked_url) - .arg(&record_path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map(Some) - }, - ); - assert!(handled); - - let record = wait_for_file(&record_path); - let mut lines = record.lines(); - let pid = lines - .next() - .expect("opener pid") - .parse::() - .expect("numeric opener pid"); - assert_eq!(lines.next(), Some(url)); - - let reaped = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped { - unsafe { - libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0); - } - } - - let _ = std::fs::remove_dir_all(&opener_dir); - assert!(reaped, "failed URL opener child {pid} was not reaped"); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let mut send_mouse = |source_id, kind, column, modifiers| { - app.handle_mouse_from_input_source( - source_id, - modified_mouse(kind, column, info.inner_rect.y, modifiers), - ); - }; - let down = MouseEventKind::Down(MouseButton::Left); - let up = MouseEventKind::Up(MouseButton::Left); - let url_x = info.inner_rect.x + col; - - send_mouse(41, down, url_x, KeyModifiers::CONTROL); - send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(41, up, url_x, KeyModifiers::empty()); - - assert_eq!(app.state.plugin_command_logs.len(), 1); - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - assert!( - input_rx.try_recv().is_err(), - "handled URL click must not leave an unmatched release for the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let url_x = info.inner_rect.x + col; - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - ); - assert_eq!(app.state.plugin_command_logs.len(), 1); - - // Opening the URL raises the browser, so the host terminal loses focus - // while the button is still down. - app.route_client_events_from( - 41, - vec![crate::raw_input::RawInputEvent::OuterFocusLost], - false, - ); - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Up(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::empty(), - ), - ); - - assert!( - input_rx.try_recv().is_err(), - "focus loss must not clear a pending URL click, so its release must stay out of the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_osc8_file_url_invokes_plugin_link_handler() { - let uri = "file:///tmp/herdr-file-repro.txt"; - let screen = format!("\x1b]8;;{uri}\x1b\\FILE\x1b]8;;\x1b\\"); - let (mut app, info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut app); - app.state - .installed_plugins - .get_mut("example.links") - .expect("test plugin") - .link_handlers[0] - .pattern = r"^file:///tmp/herdr-file-repro\.txt$".into(); - - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("matched file link should not use the system URL opener"), - ); - - assert!(handled); - let log = app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(log.plugin_id, "example.links"); - assert_eq!(log.action_id.as_deref(), Some("open")); - - let (mut unmatched_app, unmatched_info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut unmatched_app); - let unmatched_handled = unmatched_app.handle_modified_url_click_with( - 42, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - unmatched_info.inner_rect.x + 1, - unmatched_info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("unmatched file link should not use the system URL opener"), - ); - - assert!(!unmatched_handled); - assert!(unmatched_app.state.plugin_command_logs.is_empty()); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() { - let line = "see https://github.com/herdrdev/herdr/issues/398"; - let col = line.find("github").expect("url host") as u16; - - let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut ctrl_app); - ctrl_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - ctrl_info.inner_rect.x + col, - ctrl_info.inner_rect.y, - KeyModifiers::CONTROL, - )); - - let ctrl_log = ctrl_app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(ctrl_log.plugin_id, "example.links"); - assert_eq!(ctrl_log.action_id.as_deref(), Some("open")); - - let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut super_app); - super_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - super_info.inner_rect.x + col, - super_info.inner_rect.y, - KeyModifiers::SUPER, - )); - - assert!(super_app.state.plugin_command_logs.is_empty()); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_visible_url() { - let line = "see https://example.com/pr/307."; - let (app, info) = app_with_screen_bytes(line.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let col = line.find("example").expect("url host") as u16; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col) - .as_deref(), - Some("https://example.com/pr/307") - ); - assert_eq!( - app.state.url_at_pane_cell( - &app.terminal_runtimes, - pane_id, - 0, - info.inner_rect.width - 1 - ), - None - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_soft_wrapped_url() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "a".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - Some(url.as_str()) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - None - ); - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "b".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_exports_osc8_hyperlink_metadata() { - let uri = "https://example.com/target"; - let (mut app, _info) = - app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes()); - let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut app.state, - &app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 106, 20), - false, - crate::kitty_graphics::HostCellSize::default(), - ); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, cursor, &links, - ); - let ((x, y), symbol, _) = links - .iter() - .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri) - .expect("OSC 8 link cell"); - let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x); - - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!(symbol, "l"); - assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0)); - } - - #[tokio::test] - async fn pane_cell_url_resolver_prefers_osc8_hyperlink() { - let (app, _info) = app_with_screen_bytes( - b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\", - ); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1) - .as_deref(), - Some("https://example.com/hidden-target") - ); - } - - #[tokio::test] - async fn double_click_highlight_clears_after_short_delay() { - let (mut app, info) = app_with_screen_bytes(b"copied"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"copied"); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_some()); - let deadline = app - .selection_highlight_clear_deadline - .expect("highlight clear deadline"); - assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false)); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.copy_on_select = false; - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - col + 1, - row + 1, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - col + 1, - row + 1, - )); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse down"), - Bytes::from_static(b"\x1b[<0;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse drag"), - Bytes::from_static(b"\x1b[<32;4;5M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse up"), - Bytes::from_static(b"\x1b[<0;4;5m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let top_row = info.inner_rect.y; - let col = info.inner_rect.x + 2; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); - app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after wheel"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(Rect::new(0, 0, 80, 24)); - let focused_before = app.state.workspaces[0].layout.focused(); - app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h"); - - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) - .await; - - assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[cfg(unix)] - #[tokio::test] - async fn terminal_direct_edit_scrollback_opens_editor_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 20, - 5, - 4096, - b"alpha\nbeta\n", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("direct-edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e"); - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('e'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert!(content.contains("alpha")); - assert!(content.contains("beta")); - assert_eq!(app.state.mode, Mode::Terminal); - - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_command_runs_before_forwarding_to_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("direct-custom-command"); - let command = format!("printf direct > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(wait_for_file(&output_path), "direct"); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_pane_command_opens_overlay_pane() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "printf direct-pane".into(), - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_opens_layout_neutral_popup() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "sleep 1".into(), - action: crate::config::CustomCommandAction::Popup, - description: None, - width: Some(crate::popup_size::PopupSize::Cells(60)), - height: Some(crate::popup_size::PopupSize::Cells(12)), - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert!(app.state.popup_pane.is_some()); - assert!(!app - .popup_runtime() - .unwrap() - .agent_detection_enabled_for_test()); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = crate::persist::capture( - &app.state.workspaces, - &app.state.terminals, - &app.terminal_runtimes, - app.state.active, - app.state.selected, - app.state.sidebar_width, - app.state.sidebar_section_split, - app.state.collapsed_space_keys.clone(), - ); - assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1); - assert!(matches!( - snapshot.workspaces[0].tabs[0].layout, - crate::persist::LayoutSnapshot::Pane(_) - )); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_closes_after_exit() { - let mut app = app_with_spawned_workspace(); - let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap(); - - let output_path = unique_temp_path("custom-popup-command"); - let command = format!( - "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", - output_path.display() - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Popup, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!( - wait_for_file(&output_path), - format!("unset|{focused_pane_id}") - ); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - app.drain_internal_events(); - if app.state.popup_pane.is_none() { - break; - } - } - - assert!(app.state.popup_pane.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn popup_forwards_escape_instead_of_closing() { - let mut app = app_for_mouse_test(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n", - 4, - ); - runtime.scroll_up(1); - assert!(runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(runtime); - app.state.mode = Mode::Settings; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b"); - assert!(app.state.popup_pane.is_some()); - assert_eq!( - app.popup_runtime() - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .map(|metrics| metrics.offset_from_bottom), - Some(0) - ); - } - - #[tokio::test] - async fn local_popup_input_waits_for_channel_capacity() { - let mut app = app_for_mouse_test(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1); - runtime - .try_send_bytes(Bytes::from_static(b"queued")) - .unwrap(); - app.install_test_popup_runtime(runtime); - app.state.mode = Mode::Settings; - - let mut send = Box::pin( - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())), - ); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(20), &mut send) - .await - .is_err() - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued"); - send.await; - assert_eq!(rx.recv().await.unwrap().as_ref(), b"x"); - } - - #[tokio::test] - async fn alt_backspace_is_forwarded_to_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); - let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( - info.inner_rect.width, - info.inner_rect.height, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - app.handle_terminal_key_headless(key); - - let bytes = rx.try_recv().unwrap(); - assert_eq!(bytes.as_ref(), b"\x1b\x7f"); - assert!(rx.try_recv().is_err()); - } - - fn app_with_plain_scrollback( - line_count: usize, - ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut workspace = Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let pane_info = pane_infos[0].clone(); - workspace.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - pane_info.inner_rect.width, - pane_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(line_count), - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, pane_id, pane_info) - } - - fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("pane scroll metrics") - .offset_from_bottom - } - - fn physical_page_up(repeat_count: u16) -> TerminalKey { - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record( - crate::input::WindowsKeyRecord { - key_down: true, - repeat_count, - virtual_key_code: 0x21, - virtual_scan_code: 0x49, - unicode: 0, - control_key_state: 0x0100, - }, - ) - } - - #[tokio::test] - async fn page_up_scrolls_plain_shell_pane() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize - ); - } - - #[tokio::test] - async fn consumed_page_up_preserves_separate_and_grouped_repeats() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); - let page_up = physical_page_up(1); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(page_up.clone()), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Release), - ), - ], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 2 - ); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 5 - ); - } - - #[tokio::test] - async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); - let page_up = physical_page_up(1); - for key in [ - page_up.clone(), - page_up.clone().with_kind(KeyEventKind::Repeat), - page_up.clone().with_kind(KeyEventKind::Release), - ] { - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key)) - .await; - } - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 2 - ); - - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3))) - .await; - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 5 - ); - } - - #[tokio::test] - async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - first_info.inner_rect.width, - first_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(128), - ), - ); - let (second_runtime, mut second_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1h\x1b[>15u", - 3, - ); - ws.tabs[0].runtimes.insert(second_pane, second_runtime); - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, first_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - let first_repeat = second_rx.try_recv().expect("first forwarded repeat"); - let second_repeat = second_rx.try_recv().expect("owned repeat"); - let release = second_rx.try_recv().expect("owned release"); - assert_eq!(first_repeat, second_repeat); - assert_ne!(second_repeat, release); - assert!(second_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, _popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - let popup_terminal_id = app - .state - .popup_pane - .as_ref() - .expect("popup installed") - .terminal_id - .clone(); - app.terminal_runtimes.remove(&popup_terminal_id); - - let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key( - key.clone() - .with_kind(KeyEventKind::Repeat) - .with_repeat_count(1), - ), - crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert!(app.state.popup_pane.is_none()); - assert!(input_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn consumed_repeat_stays_suppressed_after_context_returns() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128); - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - let after_press = pane_scroll_offset(&app, pane_id); - - let (popup_runtime, mut popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - app.close_popup_pane(); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - assert!(popup_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn page_down_returns_to_bottom_after_page_up() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(pane_scroll_offset(&app, pane_id) > 0); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::PageDown, - KeyModifiers::empty(), - )); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_release_does_not_scroll_plain_shell_pane_again() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = pane_scroll_offset(&app, pane_id); - assert_eq!(after_press, pane_info.inner_rect.height as usize); - - app.handle_terminal_key_headless( - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()) - .with_kind(KeyEventKind::Release), - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - } - - #[tokio::test] - async fn modified_page_up_does_not_host_scroll_plain_shell_pane() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1002h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_primary_screen_application_cursor_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let forwarded = input_rx.try_recv().expect("forwarded PageUp"); - assert_eq!(forwarded.as_ref(), b"\x1b[5~"); - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_scrolls_shell_like_decckm_with_bracketed_paste() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - // zsh enables DECCKM via smkx and bracketed paste together; bash/fish do not. - let mut bytes = b"\x1b[?1h\x1b[?2004h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert!( - input_rx.try_recv().is_err(), - "PageUp should not reach the shell" - ); - assert_eq!( - pane_scroll_offset(&app, pane_id), - info.inner_rect.height as usize - ); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index c28f62da..90ee33b8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,6 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `input.rs` — key/mouse → action translation pub(crate) mod actions; mod agent_resume; @@ -12,15 +11,13 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; mod api; mod api_helpers; pub(crate) use api_helpers::limit_snapshot_lines; -mod config_io; mod creation; +mod custom_commands; mod git_refresh; mod ids; -mod input; pub(crate) mod pane_graphics; mod popup; mod runtime; -mod runtime_mutations; mod session; pub mod state; mod tab_bar_status; @@ -30,31 +27,20 @@ mod theme_sync; mod window_title; mod worktrees; -use std::collections::{HashMap, HashSet}; -use std::future::pending; -use std::io::{self, Write}; +use std::collections::HashMap; +#[cfg(unix)] +use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); -const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); -const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500); -const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2); -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, terminal, -}; use ratatui::layout::Rect; -use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; use tracing::info; @@ -80,21 +66,37 @@ pub(crate) struct OverlayPaneState { temp_files: Vec, } -#[derive(Debug, Clone, Copy)] -pub(crate) struct PaneClickState { - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - at: Instant, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppPolicy { + pub(crate) restore_session: bool, + pub(crate) persist_session: bool, + pub(crate) persist_plugin_registry: bool, + pub(crate) background_updates: bool, } -impl PaneClickState { - fn is_double_click_for(self, next: Self) -> bool { - self.pane_id == next.pane_id - && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW - && self.viewport_row.abs_diff(next.viewport_row) <= 1 - && self.col.abs_diff(next.col) <= 1 - } +impl AppPolicy { + pub(crate) const PRODUCTION: Self = Self { + restore_session: true, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; + + #[cfg(test)] + pub(crate) const TEST: Self = Self { + restore_session: false, + persist_session: false, + persist_plugin_registry: false, + background_updates: false, + }; + + #[cfg(unix)] + pub(crate) const HANDOFF_REPLACEMENT: Self = Self { + restore_session: false, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; } pub struct App { @@ -109,12 +111,9 @@ pub struct App { pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) event_hub: crate::api::EventHub, pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, - pub(crate) no_session: bool, - pub(crate) input_rx: Option>, - pub(crate) last_terminal_size: Option<(u16, u16)>, + pub(crate) policy: AppPolicy, pub(crate) config_diagnostic_deadline: Option, pub(crate) toast_deadline: Option, - pub(crate) copy_feedback_deadline: Option, pub(crate) last_api_notification_at: Option, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, @@ -126,10 +125,6 @@ pub struct App { pub(crate) pending_api_worktree_removes: HashMap, pub(crate) pending_api_worktree_remove_paths: HashMap, pub(crate) next_api_worktree_operation_id: u64, - pub(crate) last_sidebar_divider_click: Option, - pub(crate) last_pane_click: Option, - pub(crate) pending_url_click_sources: HashSet, - pub(crate) next_resize_poll: Instant, pub(crate) next_auto_update_check: Option, pub(crate) next_agent_manifest_update_check: Option, pub(crate) update_version_check_enabled: bool, @@ -137,8 +132,6 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, - pub(crate) selection_autoscroll_deadline: Option, - pub(crate) selection_highlight_clear_deadline: Option, pub(crate) session_save_deadline: Option, pub(crate) session_save_thread: Option>, pub(crate) detached_process_children: Vec, @@ -153,91 +146,30 @@ pub struct App { pub(crate) last_render_at: Option, /// Last attempt that could update a connected presentation surface. pub(crate) last_presentation_at: Option, - pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc, pub(crate) render_dirty: Arc, pub(crate) full_redraw_pending: bool, pub(crate) overlay_panes: HashMap, - pub(crate) local_terminal_notifications: bool, - /// Whether this process applies `AppEvent::PrefixInputSource` to the host input source. - /// The headless server sets this to false: the switch belongs to the foreground client, - /// even when an App-internal drain consumes the event before the forwarding drain. - pub(crate) local_input_source_switch: bool, pub(crate) config_reloaded_from_disk: bool, - prefix_input_source: Box, + client_shell_keybindings_profile: Option, + endpoint_commands: custom_commands::EndpointCommandRegistry, } pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64; -pub(crate) enum LoopEvent { - Timer, - Internal(AppEvent), - Api(Box), - RawInput(crate::raw_input::RawInputEvent), - InputClosed, - RenderRequested, +fn auto_updates_enabled(background_updates: bool) -> bool { + background_updates && !cfg!(debug_assertions) } -struct SyncOutputGuard; - -impl SyncOutputGuard { - fn begin() -> io::Result { - let mut stdout = io::stdout().lock(); - stdout.write_all(b"\x1b[?2026h")?; - stdout.flush()?; - Ok(Self) - } +fn background_update_check_enabled(background_updates: bool, check_enabled: bool) -> bool { + auto_updates_enabled(background_updates) && check_enabled } -impl Drop for SyncOutputGuard { - fn drop(&mut self) { - let mut stdout = io::stdout().lock(); - let _ = stdout.write_all(b"\x1b[?2026l"); - let _ = stdout.flush(); - } -} - -async fn recv_raw_input_or_pending( - input_rx: Option<&mut mpsc::Receiver>, -) -> Option { - match input_rx { - Some(rx) => rx.recv().await, - None => pending().await, - } -} - -async fn sleep_until_or_pending(deadline: Option) { - match deadline { - Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, - None => pending().await, - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TerminalInputTarget { - terminal_id: crate::terminal::TerminalId, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { - Pane, - Popup(crate::terminal::TerminalId), -} - -pub(crate) type InputSourceId = u64; -const LOCAL_INPUT_SOURCE: InputSourceId = 0; - -fn auto_updates_enabled(no_session: bool) -> bool { - !no_session && !cfg!(debug_assertions) -} - -fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> bool { - auto_updates_enabled(no_session) && check_enabled -} - -fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { - if no_session { +fn load_plugin_registry( + persist_plugin_registry: bool, +) -> crate::app::state::InstalledPluginRegistry { + if !persist_plugin_registry { return std::collections::HashMap::new(); } let entries = crate::persist::plugin_registry::load(); @@ -393,10 +325,33 @@ fn resolve_effective_theme( ) } +pub(crate) fn client_theme_runtime_from_config(config: &Config) -> state::ThemeRuntimeConfig { + theme_runtime_config(config, true) +} + +pub(crate) fn client_palette_for_theme( + runtime: &state::ThemeRuntimeConfig, + name: &str, +) -> state::Palette { + resolve_palette_for_theme_name(name, "catppuccin", runtime, None) +} + +pub(crate) fn client_palette_from_config(config: &Config) -> state::Palette { + let runtime = client_theme_runtime_from_config(config); + resolve_effective_theme(&runtime, None).0 +} + +pub(crate) fn client_palette_for_appearance( + runtime: &state::ThemeRuntimeConfig, + appearance: crate::terminal_theme::HostAppearance, +) -> state::Palette { + resolve_effective_theme(runtime, Some(appearance)).0 +} + impl App { pub fn new( config: &Config, - no_session: bool, + policy: AppPolicy, config_diagnostic: Option, api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, @@ -410,24 +365,8 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let ( - workspaces, - active, - selected, - sidebar_width, - sidebar_width_source, - sidebar_section_split, - collapsed_space_keys, - ) = if no_session { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + let (workspaces, active, selected) = if !policy.restore_session { + (Vec::new(), None, 0) } else if let Some(snap) = crate::persist::load() { let history = config .experimental @@ -451,67 +390,19 @@ impl App { restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); - ( - Vec::new(), - None, - 0, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (Vec::new(), None, 0) } else { crate::logging::session_restored(ws.len(), "ok"); let active = snap.active.filter(|&i| i < ws.len()); let selected = snap.selected.min(ws.len().saturating_sub(1)); - ( - ws, - active, - selected, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (ws, active, selected) } } else { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + (Vec::new(), None, 0) }; let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - // Validate sidebar bounds before they reach any `u16::clamp(min, max)` - // call: `clamp` panics when `min > max`. On bad config, fall back to - // the built-in defaults rather than crashing on the first render. - let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds( - config.ui.sidebar_min_width, - config.ui.sidebar_max_width, - ) - .unwrap_or_else(|| { - tracing::warn!( - min = config.ui.sidebar_min_width, - max = config.ui.sidebar_max_width, - "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)" - ); - (18, 36) - }); - let worktree_directory = crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); @@ -530,11 +421,7 @@ impl App { let startup_product_announcement = crate::product_announcements::load_unseen_for_current_version(); - let mode = if config.should_show_onboarding() { - state::Mode::Onboarding - } else if startup_product_announcement.is_some() { - state::Mode::ProductAnnouncement - } else if active.is_some() { + let mode = if active.is_some() { state::Mode::Terminal } else { state::Mode::Navigate @@ -560,33 +447,9 @@ impl App { selected, mode, should_quit: false, - detach_exits: no_session, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory, - collapsed_space_keys, - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, + latest_release_notes, product_announcement: startup_product_announcement.map(|announcement| { state::ProductAnnouncementState { version: announcement.version, @@ -597,36 +460,10 @@ impl App { preview: announcement.preview, } }), - keybind_help: state::KeybindHelpState::default(), - navigator: state::NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: state::ViewState { - layout: state::ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: HashMap::new(), - tab_presses: HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available, update_install_command, latest_release_notes_available, @@ -634,61 +471,33 @@ impl App { config_diagnostic, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code, prefix_mods, headless_size: config.headless_size(), - default_sidebar_width: config.ui.sidebar_width, - sidebar_width, - sidebar_min_width, - sidebar_max_width, - mobile_width_threshold: config.ui.mobile_width_threshold, - sidebar_width_source, - sidebar_width_auto: false, - sidebar_collapsed: config.ui.sidebar_start_collapsed, - sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, - sidebar_section_split, agent_panel_sort, - status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, - mouse_capture: config.ui.mouse_capture, - copy_on_select: config.ui.copy_on_select, - right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), - right_click_passthrough: None, - redraw_on_focus_gained: config.ui.redraw_on_focus_gained, - mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, - prompt_new_tab_name: config.ui.prompt_new_tab_name, - prompt_new_workspace_name: config.ui.prompt_new_workspace_name, pane_borders: config.ui.pane_borders, pane_outer_borders: config.ui.pane_outer_borders, pane_scrollbars: config.ui.pane_scrollbars, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, - hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, - tab_bar_position: config.ui.tab_bar_position, tab_bar_right: Vec::new(), tab_bar_right_separator: String::new(), - pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), - switch_ascii_input_source_in_prefix: config - .experimental - .switch_ascii_input_source_in_prefix, kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), shell_mode: config.terminal.shell_mode, new_terminal_cwd: config.terminal.new_cwd.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, - accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), - local_sound_playback: true, toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), palette: theme_palette, @@ -696,29 +505,19 @@ impl App { theme_runtime, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: state::SettingsState { - section: state::SettingsSection::Theme, - list: state::SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: crate::integration::integration_recommendations(), agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), - integration_install_messages: Vec::new(), - installed_plugins: load_plugin_registry(no_session), + installed_plugins: load_plugin_registry(policy.persist_plugin_registry), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), - confirm_close_workspace_id: None, }; state.terminals = restored_terminals; @@ -730,13 +529,15 @@ impl App { cwd.as_deref().and_then(crate::workspace::git_branch); } - // Background auto-update is disabled in monolithic no-session mode + // Background auto-update is disabled for non-persistent test apps // and in debug/test builds so local development never mutates the // running binary out from under spawned test processes. let version_check_enabled = - background_update_check_enabled(no_session, config.update.version_check); - let manifest_check_enabled = - background_update_check_enabled(no_session, config.update.manifest_check); + background_update_check_enabled(policy.background_updates, config.update.version_check); + let manifest_check_enabled = background_update_check_enabled( + policy.background_updates, + config.update.manifest_check, + ); if version_check_enabled { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); @@ -754,11 +555,13 @@ impl App { .get(idx) .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); + let client_shell_keybindings_profile = config.local_keybindings_profile_toml().ok(); + let endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands); let mut app = Self { config_diagnostic_deadline: None, toast_deadline: None, - copy_feedback_deadline: None, last_api_notification_at: None, state, pane_graphics: pane_graphics::Runtime::default(), @@ -778,10 +581,6 @@ impl App { pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), next_api_worktree_operation_id: 1, - last_sidebar_divider_click: None, - last_pane_click: None, - pending_url_click_sources: HashSet::new(), - next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), next_agent_manifest_update_check: manifest_check_enabled @@ -799,26 +598,20 @@ impl App { tab_bar_commands: Vec::new(), next_tab_bar_datetime_refresh: None, window_title_template: None, - selection_autoscroll_deadline: None, - selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, last_presentation_at: None, - input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, - no_session, - input_rx: None, - last_terminal_size: terminal::size().ok(), + policy, render_notify, render_dirty, full_redraw_pending: false, overlay_panes: HashMap::new(), - local_terminal_notifications: true, - local_input_source_switch: true, config_reloaded_from_disk: false, - prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), + client_shell_keybindings_profile, + endpoint_commands, }; app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator); app.configure_window_title(&config.ui.window_title); @@ -837,7 +630,13 @@ impl App { crate::handoff_runtime::ImportedHandoffRuntime, >, ) -> io::Result { - let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub); + let mut app = Self::new( + config, + AppPolicy::HANDOFF_REPLACEMENT, + config_diagnostic, + api_rx, + event_hub, + ); let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( snapshot, config.advanced.scrollback_limit_bytes, @@ -850,20 +649,6 @@ impl App { )?; let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces); - app.no_session = false; - app.state.installed_plugins = load_plugin_registry(app.no_session); - let now = Instant::now(); - if background_update_check_enabled(app.no_session, app.update_version_check_enabled) { - app.next_auto_update_check = app - .state - .update_available - .is_none() - .then_some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - if background_update_check_enabled(app.no_session, app.update_manifest_check_enabled) { - app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - app.state.detach_exits = false; app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; @@ -874,14 +659,6 @@ impl App { app.state.selected = snapshot .selected .min(app.state.workspaces.len().saturating_sub(1)); - if let Some(width) = snapshot.sidebar_width { - app.state.sidebar_width = width; - app.state.sidebar_width_source = state::SidebarWidthSource::Persisted; - } - if let Some(split) = snapshot.sidebar_section_split { - app.state.sidebar_section_split = split; - } - app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone(); app.state.mode = if app.state.active.is_some() { state::Mode::Terminal } else { @@ -906,367 +683,15 @@ impl App { self.terminal_runtimes.assume_handoff_ownership(); } - fn request_repaint(&mut self) { - self.full_redraw_pending = true; - } - - pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { - // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`; - // the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the - // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore - // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII. - let active = match ( - previous_mode.wants_ascii_input(), - self.state.mode.wants_ascii_input(), - ) { - (false, true) if self.state.switch_ascii_input_source_in_prefix => true, - (true, false) => false, - _ => return, - }; - if let Err(err) = self - .event_tx - .try_send(crate::events::AppEvent::PrefixInputSource { active }) - { - tracing::warn!(active, %err, "failed to queue prefix input-source change"); - } - } - - pub(crate) fn handle_internal_event_with_prefix_sync( - &mut self, - event: crate::events::AppEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = self.handle_internal_event_with_render_impact(event); - self.sync_prefix_input_source(previous_mode); - changed - } - - #[cfg(test)] - pub(crate) fn set_prefix_input_source( - &mut self, - source: Box, - ) { - self.prefix_input_source = source; - } - - pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - if self.input_rx.is_none() { - self.input_rx = Some(crate::raw_input::spawn_input_reader()); - } - self.query_host_terminal_theme(); - - let mut needs_render = true; - let mut sent_window_title: Option> = None; - let mut host_mouse_capture_active = self.state.mouse_capture; - let mut host_keyboard_report_all_active = false; - - while !self.state.should_quit { - self.reap_finished_detached_processes(); - if self.render_dirty.is_pending() { - needs_render = true; - } - // Drain a bounded internal-event batch for responsiveness. API handlers - // perform an exhaustive drain before reading pane/runtime state. - if self.drain_internal_events() { - needs_render = true; - } - if self.expire_due_metadata(Instant::now()) { - needs_render = true; - } - if self.drain_api_requests() { - needs_render = true; - } - - self.sync_focus_events(); - self.sync_session_save_schedule(); - - let now = Instant::now(); - if self.handle_scheduled_tasks(now, needs_render) { - needs_render = true; - } - - if self.state.request_complete_onboarding { - self.state.request_complete_onboarding = false; - self.open_settings_from_onboarding(); - needs_render = true; - } - - if self.state.request_new_workspace { - self.state.request_new_workspace = false; - self.runtime_workspace_create( - "tui.workspace.create", - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if self.state.request_new_tab { - self.state.request_new_tab = false; - let label = self.state.requested_new_tab_name.take(); - self.runtime_tab_create( - "tui.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_new_linked_worktree.take() { - self.open_new_linked_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_open_existing_worktree.take() { - self.open_existing_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(cwd) = self.state.request_new_workspace_cwd.take() { - self.runtime_workspace_create( - "tui.workspace.create_cwd", - crate::api::schema::WorkspaceCreateParams { - cwd: Some(cwd.display().to_string()), - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() { - self.open_remove_linked_worktree_confirmation(ws_idx); - needs_render = true; - } - - if self.state.request_submit_worktree_create { - self.state.request_submit_worktree_create = false; - self.submit_worktree_create_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_open { - self.state.request_submit_worktree_open = false; - self.submit_worktree_open_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_remove { - self.state.request_submit_worktree_remove = false; - self.submit_worktree_remove_via_api(); - needs_render = true; - } - - if self.state.request_reload_config { - self.state.request_reload_config = false; - self.reload_config(); - needs_render = true; - } - - if self.ensure_default_workspace() { - needs_render = true; - } - - if self.pane_graphics.retain_live_panes(&self.state) { - needs_render = true; - } - - let now = Instant::now(); - self.render_dirty - .set_immediate_pty_sources(self.state.app_surface_pane_ids()); - self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; - self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; - - if needs_render && self.can_render_now(now) { - let render_request = self.render_dirty.take(); - self.sync_terminal_titles(&render_request.terminal_title_sources); - if self.window_title_configured() { - let title = self - .window_title() - .and_then(|title| crate::config::sanitize_window_title_text(&title)); - if sent_window_title.as_ref() != Some(&title) { - crate::terminal_effects::write_window_title( - &mut std::io::stdout(), - title.as_deref(), - )?; - sent_window_title = Some(title); - } - } - let _sync_output = SyncOutputGuard::begin()?; - let kitty_graphics_enabled = self.state.kitty_graphics_enabled; - if self.full_redraw_pending { - for cell in &mut terminal.current_buffer_mut().content { - cell.set_skip(true); - } - terminal.swap_buffers(); - self.full_redraw_pending = false; - } - let mut cell_size = self.state.host_cell_size; - terminal.draw(|frame| { - let area = frame.area(); - if kitty_graphics_enabled { - if let Some(observed_cell_size) = - crate::kitty_graphics::HostCellSize::try_from_terminal(area) - { - self.state.host_cell_size = observed_cell_size; - cell_size = observed_cell_size; - } else if !cell_size.is_known() { - cell_size = - crate::kitty_graphics::HostCellSize::fallback_for_area(area); - } - crate::ui::compute_view_with_cell_size( - &mut self.state, - &self.terminal_runtimes, - area, - cell_size, - ); - } else { - crate::ui::compute_view_with_runtime_registry( - &mut self.state, - &self.terminal_runtimes, - area, - ); - } - crate::ui::render_with_runtime_registry( - &self.state, - &self.terminal_runtimes, - frame, - ); - })?; - if kitty_graphics_enabled { - crate::kitty_graphics::paint_local_pane_graphics( - &self.state, - &self.pane_graphics, - &self.terminal_runtimes, - cell_size, - )?; - } - self.sync_pending_agent_resume_deadline(now); - if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - self.record_render_attempt(now, true); - needs_render = false; - continue; - } - - let next_deadline = self.next_loop_deadline(now, needs_render); - let event = { - let input_rx = self.input_rx.as_mut(); - tokio::select! { - maybe_api = self.api_rx.recv() => match maybe_api { - Some(msg) => LoopEvent::Api(Box::new(msg)), - None => LoopEvent::Timer, - }, - maybe_ev = self.event_rx.recv() => match maybe_ev { - Some(ev) => LoopEvent::Internal(ev), - None => LoopEvent::Timer, - }, - maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input { - Some(input) => LoopEvent::RawInput(input), - None => LoopEvent::InputClosed, - }, - _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, - _ = self.render_notify.notified() => LoopEvent::RenderRequested, - } - }; - - match event { - LoopEvent::Timer => {} - LoopEvent::Internal(ev) => { - if self.handle_internal_event_with_prefix_sync(ev) { - needs_render = true; - } - } - LoopEvent::Api(msg) => { - if self.handle_api_request_message(*msg) { - needs_render = true; - } - } - LoopEvent::RawInput(input) => { - if self.handle_raw_input_batch(input).await { - needs_render = true; - } - } - LoopEvent::InputClosed => { - self.input_rx = None; - } - LoopEvent::RenderRequested => { - if self.render_dirty.is_pending() { - needs_render = true; - } - } - } - } - - // Save session on exit (skip in --no-session mode) - if !self.no_session { - self.save_session_now(); - } - - Ok(()) - } - - fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> { - let desired = self.host_keyboard_report_all_requested(); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?; - *active = desired; - Ok(()) - } - - fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { - let desired = self - .state - .should_capture_host_mouse_from(&self.terminal_runtimes); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - if desired { - execute!(io::stdout(), EnableMouseCapture)?; - } else { - execute!(io::stdout(), DisableMouseCapture)?; - } - *active = desired; - Ok(()) - } - pub(crate) fn ensure_default_workspace(&mut self) -> bool { - if !self.state.workspaces.is_empty() - || self.state.mode == Mode::Onboarding - || self.state.pending_workspace_create_cwd.is_some() - { + if !self.state.workspaces.is_empty() { return false; } - let previous_mode = self.state.mode; - let preserve_mode = matches!( - previous_mode, - Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings - ); let cwd = self.resolve_new_terminal_cwd(None); match self.create_workspace_with_options(cwd, true) { - Ok(_) => { - if preserve_mode { - self.state.mode = previous_mode; - } - true - } + Ok(_) => true, Err(err) => { tracing::error!(err = %err, "failed to create default workspace"); self.state.mode = Mode::Navigate; @@ -1275,14 +700,7 @@ impl App { } } - pub(crate) fn dismiss_release_notes(&mut self) { - let preview = self - .state - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - - self.state.release_notes = None; + fn mark_release_notes_seen(&mut self, preview: bool) { if !preview { if let Err(err) = crate::release_notes::mark_current_version_seen() { self.state.config_diagnostic = @@ -1290,16 +708,6 @@ impl App { self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); } } - - if self.state.product_announcement.is_some() { - self.state.mode = Mode::ProductAnnouncement; - } else { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } } pub(crate) fn dismiss_product_announcement(&mut self) { @@ -1314,87 +722,6 @@ impl App { } } } - - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn scroll_release_notes(&mut self, delta: i16) { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = if delta.is_negative() { - notes.scroll.saturating_sub(delta.unsigned_abs()) - } else { - notes.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn scroll_product_announcement(&mut self, delta: i16) { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = if delta.is_negative() { - announcement.scroll.saturating_sub(delta.unsigned_abs()) - } else { - announcement.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn open_settings_from_onboarding(&mut self) { - self.mark_onboarding_complete(); - self.refresh_integration_recommendations(); - crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations); - } - - pub(crate) fn refresh_integration_recommendations(&mut self) { - self.state.integration_recommendations = crate::integration::integration_recommendations(); - } - - pub(crate) fn install_recommended_integrations(&mut self) { - let targets = self - .state - .integration_recommendations - .iter() - .filter(|recommendation| recommendation.needs_install()) - .map(|recommendation| recommendation.target) - .collect::>(); - - self.state.integration_install_messages.clear(); - if targets.is_empty() { - self.state - .integration_install_messages - .push("all detected integrations are current".to_string()); - return; - } - - for target in targets { - let label = crate::integration::integration_target_label(target); - match crate::integration::install_target(target) { - Ok(messages) => { - self.state - .integration_install_messages - .push(format!("installed {label}")); - self.state - .integration_install_messages - .extend(messages.into_iter().filter(|message| { - message.starts_with(crate::integration::INSTALL_WARNING_PREFIX) - })); - } - Err(err) => self - .state - .integration_install_messages - .push(format!("{label}: {err}")), - } - } - - self.state.integration_recommendations = crate::integration::integration_recommendations(); - self.state.mark_session_dirty(); } pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { @@ -1431,6 +758,8 @@ impl App { } } }; + self.endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&self.state.keybinds.custom_commands); self.sync_toast_deadline(previous_toast); report } @@ -1452,6 +781,12 @@ impl App { self.state.prefix_code = live.prefix.0; self.state.prefix_mods = live.prefix.1; self.state.keybinds = live.keybinds; + match config.local_keybindings_profile_toml() { + Ok(profile) => self.client_shell_keybindings_profile = Some(profile), + Err(err) => diagnostics.push(format!( + "failed to publish server keybindings: {err}; kept previous keybindings" + )), + } diagnostics.extend(keybind_diagnostics); } Err(keybind_diagnostics) => { @@ -1480,44 +815,14 @@ impl App { &config.ui.window_title, )); - self.state.default_sidebar_width = config.ui.sidebar_width; - if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { - self.state.sidebar_width = config.ui.sidebar_width; - } - self.state.sidebar_min_width = config.ui.sidebar_min_width; - self.state.sidebar_max_width = config.ui.sidebar_max_width; - self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode; - self.state.mobile_width_threshold = config.ui.mobile_width_threshold; - // Re-clamp the live width to the new bounds. No source guard — bounds - // always apply, including to widths owned by Persisted or Manual. - self.state.sidebar_width = self - .state - .sidebar_width - .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); - self.state.mouse_capture = config.ui.mouse_capture; - self.state.copy_on_select = config.ui.copy_on_select; - if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained { - self.state.request_client_config_reload = true; - } - self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; - if self.loaded_host_cursor != config.ui.host_cursor { - self.state.request_client_config_reload = true; - } self.loaded_host_cursor = config.ui.host_cursor; - self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); - self.state.right_click_passthrough_modifiers = - config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; - self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; - self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name; self.state.pane_borders = config.ui.pane_borders; self.state.pane_outer_borders = config.ui.pane_outer_borders; self.state.pane_scrollbars = config.ui.pane_scrollbars; self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; - self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; - self.state.tab_bar_position = config.ui.tab_bar_position; self.configure_tab_bar_status( &config.ui.tab_bar_right, &config.ui.tab_bar_right_separator, @@ -1525,14 +830,8 @@ impl App { self.configure_window_title(&config.ui.window_title); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); - self.state.agent_panel_scroll = 0; - self.state.accent = crate::config::parse_color(&config.ui.accent); - if !self.state.local_sound_playback && self.state.sound != config.ui.sound { - self.state.request_client_config_reload = true; - } self.state.sound = config.ui.sound.clone(); self.state.toast_config = config.ui.toast.clone(); } @@ -1543,7 +842,6 @@ impl App { self.state.kitty_graphics_enabled = config.experimental.kitty_graphics; crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics); if was_kitty_graphics_enabled && !config.experimental.kitty_graphics { - let _ = crate::kitty_graphics::clear_all_host_graphics(); self.pane_graphics.clear(); self.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); } @@ -1554,10 +852,7 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); - self.state.switch_ascii_input_source_in_prefix = - config.experimental.switch_ascii_input_source_in_prefix; self.persist_pane_history = config.experimental.pane_history; - self.state.pane_history_persistence = config.experimental.pane_history; if !self.persist_pane_history { crate::persist::clear_history(); } @@ -1586,7 +881,7 @@ impl App { self.next_auto_update_check = None; } else if !previous_version_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_version_check_enabled, ) && self.state.update_available.is_none() @@ -1598,7 +893,7 @@ impl App { self.next_agent_manifest_update_check = None; } else if !previous_manifest_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_manifest_check_enabled, ) { @@ -1654,459 +949,27 @@ impl App { } } + self.state.request_client_config_reload = true; crate::config::ConfigReloadReport { status, diagnostics, } } } - -// --------------------------------------------------------------------------- -// Input routing for headless server mode -// --------------------------------------------------------------------------- - -impl App { - pub(crate) fn terminal_input_context(&self) -> Option { - if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) - } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) - } else { - None - } - } - - fn execute_repeat_plan_headless( - &mut self, - source_id: InputSourceId, - lease_key: input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: input::RepeatPlan, - ) { - match plan { - input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target_headless(&target, key) { - self.input_leases.remove(&lease_key); - } - } - input::RepeatPlan::Reprocess { - context, - repetitions, - tracked, - } => { - let key = key - .with_kind(crossterm::event::KeyEventKind::Repeat) - .with_repeat_count(1); - let mut forwarded_target = None; - for _ in 0..repetitions { - if let Some(target) = &forwarded_target { - if !self.forward_terminal_key_to_target_headless(target, key.clone()) { - self.input_leases.remove(&lease_key); - break; - } - continue; - } - let current_context = self.terminal_input_context(); - if !self.input_leases.reprocess_allowed( - lease_key, - &context, - current_context.as_ref(), - tracked, - ) { - break; - } - if let Some(target) = - self.handle_terminal_key_headless_from(source_id, key.clone()) - { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - } - input::RepeatPlan::Ignore => {} - } - } - - /// Routes raw input bytes from a client through the existing input pipeline. - /// - /// The input bytes are parsed into `RawInputEvent`s and then processed. - /// In terminal mode, keys are routed through the same semantic - /// key-handling path as monolithic herdr so they are re-encoded for the - /// focused pane's negotiated keyboard protocol instead of passing host - /// terminal escape sequences through unchanged. - #[cfg(test)] - pub(crate) fn route_client_input(&mut self, data: Vec) { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - self.route_client_events(events, true); - } - - pub(crate) fn route_client_pixel_mouse( - &mut self, - source_id: InputSourceId, - data: &[u8], - geometry: crate::input::mouse::HostGeometry, - ) -> bool { - let Some((x, y)) = crate::input::mouse::parse_report(data) else { - return false; - }; - let Some((column, row)) = geometry.cell(x, y) else { - return false; - }; - let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { - return false; - }; - let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) { - return false; - } - self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - self.route_client_events_from(source_id, std::mem::take(&mut events), false); - self.state.host_mouse_pixels = None; - true - } - - pub(crate) fn route_client_events( - &mut self, - events: Vec, - apply_host_terminal_theme: bool, - ) { - self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); - } - - pub(crate) fn route_client_events_from( - &mut self, - source_id: InputSourceId, - events: Vec, - apply_host_terminal_theme: bool, - ) { - for event in events { - let previous_mode = self.state.mode; - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = input::InputLeaseKey::new(source_id, &key); - let key = self.input_leases.normalize_press(&lease_key, key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = if initial_context.is_some() { - self.handle_terminal_key_headless_from(source_id, key.clone()) - } else { - self.handle_non_terminal_key_headless(key.clone()); - None - }; - let resulting_context = self.terminal_input_context(); - let plan = self.input_leases.complete_press( - lease_key, - &key, - initial_context.as_ref(), - resulting_context.as_ref(), - target, - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); - let plan = self.input_leases.plan_repeat( - lease_key, - &key, - current_context.as_ref(), - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target_headless(&lease.target, key); - } - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit_headless(text.as_str()); - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse_event_headless(source_id, mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - if self.try_route_paste_to_popup(&text) { - } else if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - } else { - if let Some(ws_idx) = self.state.active { - if let Some(ws) = self.state.workspaces.get(ws_idx) { - if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - focused, - ) { - let _ = runtime.try_send_paste(text); - } - } - } - } - } - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source_headless(source_id); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - if apply_host_terminal_theme { - self.update_host_terminal_theme(kind, color); - } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - if apply_host_terminal_theme { - self.update_host_terminal_palette_colors(&colors); - } - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - if apply_host_terminal_theme { - self.set_host_terminal_appearance(appearance, true); - } - } - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {} - crate::raw_input::RawInputEvent::Unsupported => {} - } - self.sync_prefix_input_source(previous_mode); - } - } - - pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { - // Call this only when the input source is gone for good. A pending URL - // click has to outlive a plain focus change, because opening the URL - // raises the browser and costs the host terminal its focus before the - // mouse release arrives. - self.pending_url_click_sources.remove(&source_id); - self.release_input_source_headless(source_id); - } - - /// Handles a key event in non-terminal mode for the headless server. - /// - /// Uses the standalone handler functions that work on `&mut AppState` - /// since the server doesn't have the async context of the monolithic App. - fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) { - let key_event = key.as_key_event(); - if input::modal_paste_target_active(&self.state) - && input::is_modal_paste_shortcut(&key_event) - { - if let Some(text) = crate::platform::read_clipboard_text() { - self.paste_into_active_text_input(&text); - } - return; - } - - match self.state.mode { - Mode::Prefix => { - self.handle_prefix_key(key); - } - Mode::Navigate => { - self.handle_navigate_key(key); - } - Mode::Copy => { - self.handle_copy_mode_key(key); - } - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - self.handle_rename_key_via_api(key_event); - } - Mode::NewLinkedWorktree => { - self.handle_worktree_create_key(key_event); - } - Mode::OpenExistingWorktree => { - self.handle_worktree_open_key(key_event); - } - Mode::ConfirmRemoveWorktree => { - self.handle_worktree_remove_key(key_event); - } - Mode::Resize => { - self.handle_resize_key_via_api(key); - } - Mode::ConfirmClose => { - self.handle_confirm_close_key_via_api(key_event); - } - Mode::ContextMenu => { - self.handle_context_menu_key_via_api(key_event); - } - Mode::KeybindHelp => { - input::handle_keybind_help_key(&mut self.state, key); - } - Mode::GlobalMenu => { - input::handle_global_menu_key(&mut self.state, key_event); - } - Mode::Onboarding => { - self.handle_onboarding_key(key_event); - } - Mode::ReleaseNotes => { - self.handle_release_notes_key(key_event); - } - Mode::ProductAnnouncement => { - self.handle_product_announcement_key(key_event); - } - Mode::Settings => { - self.handle_settings_key(key_event); - } - Mode::Navigator => { - input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event); - } - Mode::Terminal => { - // Should not be called in terminal mode. - } - } - } - - /// Handles a mouse event for the headless server. - /// - /// Delegates to the same mouse handling logic used in the monolithic - /// mode (hit-testing against the rendered UI), which works because - /// the server's AppState maintains view geometry from virtual rendering. - fn handle_mouse_event_headless( - &mut self, - source_id: InputSourceId, - mouse: crossterm::event::MouseEvent, - ) { - self.handle_mouse_from_input_source(source_id, mouse); - } -} - #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - use std::cell::Cell; - use std::rc::Rc; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::Mutex; - fn raw_key( - code: KeyCode, - modifiers: KeyModifiers, - kind: KeyEventKind, - ) -> crate::raw_input::RawInputEvent { - crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new(code, modifiers).with_kind(kind), - ) - } - - #[cfg(windows)] - #[tokio::test] - async fn native_repeats_and_releases_follow_the_pressed_pane() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }; - let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(record); - let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - control_key_state: 0x0100, - ..record - }); - assert_ne!( - input::InputLeaseKey::new(7, &key), - input::InputLeaseKey::new(7, &enhanced) - ); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, 6); - let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - unicode: 0, - ..record - }) - .with_kind(KeyEventKind::Release); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(key.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), - crate::raw_input::RawInputEvent::Key(release), - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - for _ in 0..3 { - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - } - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") - ); - assert!(pressed_rx.try_recv().is_err()); - - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") - ); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - fn release_notes_state() -> state::ReleaseNotesState { - state::ReleaseNotesState { - version: "0.1.0".into(), - body: "notes".into(), - scroll: 0, - preview: true, - } - } - fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2131,230 +994,6 @@ mod tests { "/usr/bin/true" } - #[derive(Clone, Default)] - struct FakePrefixInputSource { - switch_calls: Rc>, - restore_calls: Rc>, - switched: Rc>, - will_switch: bool, - } - - impl FakePrefixInputSource { - fn switching() -> Self { - Self { - will_switch: true, - ..Self::default() - } - } - - fn no_op() -> Self { - Self { - will_switch: false, - ..Self::default() - } - } - } - - impl crate::platform::PrefixInputSource for FakePrefixInputSource { - fn switch_to_ascii(&mut self) { - self.switch_calls.set(self.switch_calls.get() + 1); - if self.will_switch { - self.switched.set(true); - } - } - - fn restore(&mut self) { - if self.switched.replace(false) { - self.restore_calls.set(self.restore_calls.get() + 1); - } - } - } - - /// Drain the app event channel, returning the `active` flags of any emitted - /// `PrefixInputSource` events (the host-local input-source intents). - fn drained_prefix_active(app: &mut App) -> Vec { - let mut out = Vec::new(); - while let Ok(ev) = app.event_rx.try_recv() { - if let crate::events::AppEvent::PrefixInputSource { active } = ev { - out.push(active); - } - } - out - } - - #[test] - fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix emits the ASCII-switch intent. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> Terminal emits the restore intent. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = false; - - // Entering the realm with the flag off emits nothing. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert!(drained_prefix_active(&mut app).is_empty()); - - // Leaving the realm still emits the restore (harmless if nothing was switched), so a - // mid-interaction flag toggle can't strand the host on ASCII. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn mode_wants_ascii_input_classification() { - // Allowlist: the prefix command/navigation realm wants ASCII. - for mode in [ - Mode::Prefix, - Mode::Navigate, - Mode::Navigator, - Mode::Copy, - Mode::Resize, - Mode::ConfirmClose, - Mode::ConfirmRemoveWorktree, - Mode::ContextMenu, - Mode::GlobalMenu, - Mode::KeybindHelp, - ] { - assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII"); - } - // Everything else (terminal, text entry, startup overlays) keeps the user's IME. - for mode in [ - Mode::Terminal, - Mode::RenameWorkspace, - Mode::RenameTab, - Mode::RenamePane, - Mode::NewLinkedWorktree, - Mode::OpenExistingWorktree, - Mode::Settings, - Mode::Onboarding, - Mode::ReleaseNotes, - Mode::ProductAnnouncement, - ] { - assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME"); - } - } - - #[test] - fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix switches once. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit. - app.state.mode = Mode::Navigator; - app.sync_prefix_input_source(Mode::Prefix); - app.state.mode = Mode::Resize; - app.sync_prefix_input_source(Mode::Navigator); - assert!( - drained_prefix_active(&mut app).is_empty(), - "must not switch or restore while still in the realm" - ); - - // Leaving the realm back to the terminal restores. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Resize); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_restores_when_entering_rename_text_mode() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore. - app.state.mode = Mode::RenameTab; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn handle_internal_event_prefix_input_source_applies_switch_and_restore() { - // The monolithic (in-process) path applies the host switch when it consumes the event. - let mut app = test_app(); - let fake = FakePrefixInputSource::switching(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(restore_calls.get(), 1); - } - - #[test] - fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() { - // Already-ASCII / failed-switch case: the restore on leave must stay harmless. - let mut app = test_app(); - let fake = FakePrefixInputSource::no_op(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - } - - #[tokio::test] - async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() { - // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself — - // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still - // emits the restore intent. - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // ctrl+b (the default prefix key) enters prefix mode → switch intent. - app.handle_raw_input_event(raw_key( - KeyCode::Char('b'), - KeyModifiers::CONTROL, - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Esc leaves prefix mode → restore intent. - app.handle_raw_input_event(raw_key( - KeyCode::Esc, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - fn config_env_lock() -> &'static Mutex<()> { crate::config::test_config_env_lock() } @@ -2393,7 +1032,7 @@ mod tests { let mut app = test_app(); app.git_refresh_in_flight = true; - let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed { + let changed = app.handle_internal_event_with_render_impact(AppEvent::GitStatusRefreshed { results: Vec::new(), cache_updates: Vec::new(), }); @@ -2424,10 +1063,10 @@ mod tests { result: Ok(output.map(str::to_string)), }; - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None))); - assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event( + assert!(!app.handle_internal_event_with_render_impact(event(generation, None))); + assert!(app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event( generation.wrapping_add(1), Some("stale"), ))); @@ -2474,59 +1113,6 @@ mod tests { assert!(app.render_dirty.is_pending()); } - #[test] - fn clipboard_write_event_shows_feedback_toast() { - let mut app = test_app(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.toast.is_none()); - let feedback = app.state.copy_feedback.as_ref().expect("copy feedback"); - assert_eq!(feedback.message, "copied to clipboard"); - assert!(app.copy_feedback_deadline.is_some()); - } - - #[test] - fn clipboard_feedback_can_be_disabled() { - let mut app = test_app(); - app.state.toast_config.clipboard.enabled = false; - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.copy_feedback.is_none()); - assert!(app.copy_feedback_deadline.is_none()); - } - - #[test] - fn clipboard_feedback_does_not_replace_notification_toast() { - let mut app = test_app(); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_string(), - context: "background · 2".to_string(), - position: None, - target: None, - }); - let original_toast = app.state.toast.clone(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert_eq!(app.state.toast, original_toast); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - } - #[test] fn notification_show_api_creates_herdr_toast_with_position() { let mut app = test_app(); @@ -2563,38 +1149,6 @@ mod tests { assert!(app.toast_deadline.is_some()); } - #[test] - fn notification_show_api_herdr_toast_expires() { - let mut app = test_app(); - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let response = - app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { - id: "notify".into(), - method: crate::api::schema::Method::NotificationShow( - crate::api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: crate::api::schema::NotificationShowSound::None, - }, - ), - }); - - let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - crate::api::schema::ResponseResult::NotificationShow { - shown: true, - reason: crate::api::schema::NotificationShowReason::Shown, - } - ); - let deadline = app.toast_deadline.expect("api toast deadline"); - assert!(app.handle_scheduled_tasks(deadline, false)); - assert!(app.state.toast.is_none()); - assert!(app.toast_deadline.is_none()); - } - #[test] fn notification_show_api_respects_off_delivery() { let mut app = test_app(); @@ -2765,61 +1319,17 @@ mod tests { config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } - #[test] - fn startup_uses_configured_sidebar_state() { - let mut config = Config::default(); - config.ui.sidebar_start_collapsed = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn startup_uses_redraw_on_focus_gained_config() { - let mut config = Config::default(); - config.ui.redraw_on_focus_gained = false; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(!app.state.redraw_on_focus_gained); - } - - #[test] - fn workspace_name_prompt_suppresses_default_creation_while_pending() { - let mut app = test_app(); - app.state.prompt_new_workspace_name = true; - - app.begin_tui_workspace_create("test.workspace.create"); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(!app.ensure_default_workspace()); - assert!(app.state.workspaces.is_empty()); - - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - assert!(app.state.workspaces.is_empty()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - } - - #[test] - fn startup_uses_workspace_name_prompt_config() { - let mut config = Config::default(); - config.ui.prompt_new_workspace_name = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.prompt_new_workspace_name); - } - #[test] fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { let mut config = Config::default(); @@ -2837,7 +1347,13 @@ mod tests { }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert!(!app.state.theme_runtime.auto_switch); assert_eq!(app.state.theme_name, "tokyo-night"); @@ -2850,12 +1366,19 @@ mod tests { config.theme.name = Some("tokyo-night".to_string()); config.theme.auto_switch = true; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.theme_name, "tokyo-night"); - assert!( - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true) - ); + assert!(app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + )); assert_eq!(app.state.theme_name, "tokyo-night-day"); assert_eq!(app.state.palette, state::Palette::tokyo_night_day()); @@ -2871,9 +1394,18 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!(app.state.theme_name, "gruvbox-light"); assert_eq!( @@ -2903,7 +1435,13 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!( app.state.palette.accent, @@ -2922,7 +1460,10 @@ mod tests { ratatui::style::Color::Rgb(16, 17, 18) ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!( app.state.palette.accent, @@ -2931,31 +1472,6 @@ mod tests { assert_eq!(app.state.palette.text, ratatui::style::Color::Rgb(4, 5, 6)); } - #[test] - fn inferred_background_appearance_does_not_override_explicit_report() { - let mut config = Config::default(); - config.theme.name = Some("catppuccin".to_string()); - config.theme.auto_switch = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true); - app.update_host_terminal_theme( - crate::terminal_theme::DefaultColorKind::Background, - crate::terminal_theme::RgbColor { - r: 0xff, - g: 0xff, - b: 0xff, - }, - ); - - assert_eq!( - app.state.host_terminal_appearance, - Some(crate::terminal_theme::HostAppearance::Dark) - ); - assert_eq!(app.state.theme_name, "catppuccin"); - } - #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); @@ -2969,6 +1485,81 @@ mod tests { assert_eq!(app.state.update_available.as_deref(), Some("99.99.99")); assert!(app.state.latest_release_notes_available); + assert_eq!( + app.state + .latest_release_notes + .as_ref() + .map(|notes| notes.version.as_str()), + Some("99.99.99") + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn update_ready_refreshes_cached_release_notes() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("update-ready-refreshes-release-notes"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + assert!(app.state.latest_release_notes.is_none()); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- One").unwrap(); + app.handle_internal_event(AppEvent::UpdateReady { + version: "99.99.99".into(), + install_command: "herdr update".into(), + }); + + assert_eq!( + app.state.latest_release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("99.99.99", "### Changed\n- One", true)) + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn release_notes_dismiss_api_marks_current_seen_but_keeps_preview_unseen() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("release-notes-dismiss-persistence"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let dismiss = |app: &mut App, version: &str| { + let response = app.handle_api_request(crate::api::schema::Request { + id: format!("dismiss-{version}"), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: version.to_owned(), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["type"], "ok"); + }; + let show_on_startup = || { + let stored: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(crate::release_notes::pending_path()).unwrap(), + ) + .unwrap(); + stored["show_on_startup"].as_bool() + }; + + let current = env!("CARGO_PKG_VERSION"); + crate::release_notes::save_pending(current, "### Changed\n- Current").unwrap(); + let mut app = test_app(); + dismiss(&mut app, current); + assert_eq!(show_on_startup(), Some(false)); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- Preview").unwrap(); + let mut app = test_app(); + dismiss(&mut app, "99.99.99"); + assert_eq!(show_on_startup(), Some(true)); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -3005,10 +1596,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.release_notes.is_none()); assert!(app.state.latest_release_notes_available); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -3016,7 +1612,7 @@ mod tests { } #[test] - fn startup_still_auto_opens_unseen_product_announcement() { + fn startup_loads_unseen_product_announcement_for_clients() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-product-announcement-auto-open"); let state_home = path.parent().unwrap().join("state"); @@ -3042,9 +1638,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - assert_eq!(app.state.mode, Mode::ProductAnnouncement); + assert_eq!(app.state.mode, Mode::Navigate); assert_eq!( app.state .product_announcement @@ -3052,7 +1654,6 @@ mod tests { .map(|announcement| announcement.id.as_str()), Some("startup-announcement") ); - assert!(app.state.release_notes.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); restore_xdg_state_home(original_xdg_state_home); @@ -3066,35 +1667,12 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let selection_pane = crate::layout::PaneId::alloc(); - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 1, - last_mouse_screen_row: 1, - inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2), - }); - let selection_deadline = Instant::now(); - app.selection_autoscroll_deadline = Some(selection_deadline); - app.selection_highlight_clear_deadline = Some(selection_deadline); - app.last_pane_click = Some(PaneClickState { - pane_id: selection_pane, - viewport_row: 0, - col: 0, - at: selection_deadline, - }); app.next_auto_update_check = Some(Instant::now()); app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); @@ -3113,33 +1691,8 @@ mod tests { crate::config::ToastDelivery::Herdr ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - assert!(!app.state.redraw_on_focus_gained); - assert!(!app.state.copy_on_select); - assert!(app.state.prompt_new_workspace_name); - assert!(app.state.selection.is_some()); - assert!(app.state.selection_autoscroll.is_some()); - assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline)); - assert_eq!( - app.selection_highlight_clear_deadline, - Some(selection_deadline) - ); - assert!(app.last_pane_click.is_some()); - - app.state.mode = Mode::Copy; - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(app.state.selection.is_some()); - assert_eq!( - app.state.right_click_passthrough_modifiers, - Some(KeyModifiers::CONTROL) - ); assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( @@ -3154,7 +1707,6 @@ mod tests { assert!(!app.update_manifest_check_enabled); assert!(app.next_auto_update_check.is_none()); assert!(app.next_agent_manifest_update_check.is_none()); - assert!(app.state.switch_ascii_input_source_in_prefix); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -3165,6 +1717,26 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn reload_config_requests_client_reload_for_key_only_change() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-key-only"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[keys]\nprefix = \"ctrl+a\"\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + app.state.request_client_config_reload = false; + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert_eq!(app.state.prefix_code, KeyCode::Char('a')); + assert!(app.state.request_client_config_reload); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn reload_config_requests_client_reload_for_host_cursor_only_change() { let _guard = config_env_lock().lock().unwrap(); @@ -3189,37 +1761,6 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - #[test] - fn reload_config_updates_sidebar_width_only_when_config_owned() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-width"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_width_source, - state::SidebarWidthSource::ConfigDefault - ); - - std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 34); - assert_eq!(app.state.sidebar_width, 34); - - app.state.sidebar_width = 31; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 35); - assert_eq!(app.state.sidebar_width, 31); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_updates_sidebar_token_rows() { let _guard = config_env_lock().lock().unwrap(); @@ -3233,11 +1774,9 @@ mod tests { "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n", ) .unwrap(); - app.state.agent_panel_scroll = 5; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.agent_panel_scroll, 0); assert_eq!( app.state.sidebar_agents.rows, vec![vec![ @@ -3275,146 +1814,6 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - #[test] - fn reload_config_does_not_reset_sidebar_to_startup_state() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-start-collapsed"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert!(!app.state.sidebar_collapsed); - - std::fs::write(&path, "[ui]\nsidebar_start_collapsed = true\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(!app.state.sidebar_collapsed); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_collapsed_mode() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-collapsed-mode"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Compact - ); - - std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Hidden - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_bounds_and_reclamps() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-bounds"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - // Default bounds. - assert_eq!(app.state.sidebar_min_width, 18); - assert_eq!(app.state.sidebar_max_width, 36); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - // Manually set a width and flip the source so the existing - // sidebar_width-only-when-config-owned guard does NOT update it. - app.state.sidebar_width = 30; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - - // Tightening max below the current width must re-clamp the live width - // even when source is Manual — bounds always apply. - std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 24); - assert_eq!( - app.state.sidebar_width, 24, - "manual width must re-clamp to new max" - ); - - // Loosening max leaves the live width alone (it's already within bounds). - app.state.sidebar_width = 24; - std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 60); - assert_eq!(app.state.sidebar_width, 24); - - // Raising min above the current width re-clamps upward. - std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_min_width, 30); - assert_eq!( - app.state.sidebar_width, 30, - "manual width must re-clamp up to new min" - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_mobile_width_threshold() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-mobile-width-threshold"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap(); - let report = app.reload_config(); - - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.mobile_width_threshold, 96); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn app_new_falls_back_to_default_bounds_on_inverted_config() { - let mut config = Config::default(); - config.ui.sidebar_min_width = 50; - config.ui.sidebar_max_width = 30; - - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert_eq!( - app.state.sidebar_min_width, 18, - "App::new must fall back to default min when bounds are inverted" - ); - assert_eq!( - app.state.sidebar_max_width, 36, - "App::new must fall back to default max when bounds are inverted" - ); - } - #[test] fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() { let _guard = config_env_lock().lock().unwrap(); @@ -3423,17 +1822,15 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_min = app.state.sidebar_min_width; - let original_max = app.state.sidebar_max_width; - let original_mouse_capture = app.state.mouse_capture; + let original_pane_borders = app.state.pane_borders; // Pair the bad bounds with another `[ui]` field change to confirm the // entire section is treated as invalid (not just the bounds). - let target_mouse_capture = !original_mouse_capture; + let target_pane_borders = !original_pane_borders; std::fs::write( &path, format!( - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n", - target_mouse_capture + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n", + target_pane_borders ), ) .unwrap(); @@ -3445,11 +1842,9 @@ mod tests { && diagnostic.contains("sidebar_max_width") && diagnostic.contains("greater") })); - assert_eq!(app.state.sidebar_min_width, original_min); - assert_eq!(app.state.sidebar_max_width, original_max); assert_eq!( - app.state.mouse_capture, original_mouse_capture, - "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" + app.state.pane_borders, original_pane_borders, + "[ui] is treated as invalid on bad bounds; pane_borders must not apply" ); assert_eq!( app.state.config_diagnostic.as_deref(), @@ -3501,10 +1896,10 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let target_mouse_capture = !app.state.mouse_capture; + let target_pane_borders = !app.state.pane_borders; std::fs::write( &path, - format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"), + format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"), ) .unwrap(); @@ -3515,7 +1910,7 @@ mod tests { report.diagnostics, vec!["unknown config key ui.mouse_captur; ignoring key"] ); - assert_eq!(app.state.mouse_capture, target_mouse_capture); + assert_eq!(app.state.pane_borders, target_pane_borders); assert_eq!( app.state.config_diagnostic.as_deref(), Some("config.toml has unknown keys; herdr config check") @@ -3622,85 +2017,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[test] - fn settings_save_toast_delivery_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("settings-save-toast-delivery"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Off - ); - - app.save_toast_delivery(crate::config::ToastDelivery::Terminal); - - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"terminal\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_status_indicators_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-status-indicators"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Dots - ); - - app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols); - - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Symbols - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("status_indicators = \"symbols\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_agent_panel_sort_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-agent-panel-sort"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces); - - app.save_agent_panel_sort(state::AgentPanelSort::Priority); - - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("agent_panel_sort = \"priority\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_keeps_current_state_on_invalid_toml() { let _guard = config_env_lock().lock().unwrap(); @@ -3734,323 +2050,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[tokio::test] - async fn raw_input_waits_when_reader_is_gone() { - let result = - tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn terminal_mode_handles_repeat_key_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Backspace, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(handled); - } - - #[tokio::test] - async fn monolithic_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - for kind in [ - KeyEventKind::Press, - KeyEventKind::Repeat, - KeyEventKind::Release, - ] { - app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) - .await; - } - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn outer_focus_gained_marks_visible_done_panes_seen() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - let split_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&root_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&root_pane) - .unwrap() - .seen = false; - let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&split_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&split_pane) - .unwrap() - .seen = false; - let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle; - app.state.workspaces[0].tabs[background_tab] - .panes - .get_mut(&background_pane) - .unwrap() - .seen = false; - - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.outer_terminal_focus = Some(false); - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(app.state.workspaces[0].tabs[0].panes[&root_pane].seen); - assert!(app.state.workspaces[0].tabs[0].panes[&split_pane].seen); - assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen); - } - - #[tokio::test] - async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() { - let mut app = test_app(); - app.state.redraw_on_focus_gained = false; - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(!app.full_redraw_pending); - } - - #[tokio::test] - async fn monolithic_outer_focus_events_reach_reporting_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx - .recv() - .await - .expect("forwarded focus gained report"), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - assert_eq!( - input_rx.recv().await.expect("forwarded focus lost report"), - bytes::Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_reconcile_pending_pane_focus() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-transition"); - let previous_pane = workspace.tabs[0].root_pane; - let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(previous_pane); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(next_pane, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.last_focus = Some((0, previous_pane)); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(input_rx.try_recv().is_err()); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - } - - #[tokio::test] - async fn repeat_key_events_are_ignored_outside_terminal_mode() { - let mut app = test_app(); - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(!handled); - assert_eq!(app.state.mode, Mode::ReleaseNotes); - assert!(app.state.release_notes.is_some()); - } - - #[tokio::test] - async fn modal_press_does_not_leak_repeat_into_terminal_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - let repeat_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - let release_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Release, - )) - .await; - let next_press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - - assert!(press_handled); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!repeat_handled); - assert!(!release_handled); - assert!(next_press_handled); - } - #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { @@ -4117,6 +2116,35 @@ mod tests { crate::api::schema::AgentViewClearParams::default(), ), }; + let command_invoke = crate::api::schema::Request { + id: "req_10".into(), + method: crate::api::schema::Method::CommandInvoke( + crate::api::schema::CommandInvokeParams { + command_id: "cmd_boot_1_0".into(), + workspace_id: Some("w1".into()), + tab_id: Some("w1:t1".into()), + pane_id: Some("w1:p1".into()), + selection: None, + }, + ), + }; + let announcement_dismiss = crate::api::schema::Request { + id: "req_11".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "client-shell".into(), + }, + ), + }; + let release_notes_dismiss = crate::api::schema::Request { + id: "req_12".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "0.8.2".into(), + }, + ), + }; assert!(!crate::api::request_changes_ui(&read_only)); assert!(!crate::api::request_changes_ui(&worktree_list)); @@ -4127,6 +2155,9 @@ mod tests { assert!(crate::api::request_changes_ui(&pane_focus_direction)); assert!(crate::api::request_changes_ui(&pane_resize)); assert!(crate::api::request_changes_ui(&agent_view)); + assert!(crate::api::request_changes_ui(&command_invoke)); + assert!(crate::api::request_changes_ui(&announcement_dismiss)); + assert!(crate::api::request_changes_ui(&release_notes_dismiss)); } #[test] @@ -4537,105 +2568,6 @@ mod tests { })); } - #[tokio::test] - async fn pane_split_request_targets_pane_in_background_tab() { - let _guard = config_env_lock().lock().unwrap(); - let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", exiting_test_command()); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("api-pane-split-background-tab"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("worker")); - let target_pane = workspace.tabs[background_tab].root_pane; - workspace.switch_tab(background_tab); - let background_previous_focus = - workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.switch_tab(0); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let split_cwd = std::env::temp_dir(); - let target_terminal_id = app.state.workspaces[0] - .pane_state(target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .cwd = split_cwd.clone(); - app.state.active = Some(0); - app.state.selected = 0; - app.state - .focus_pane_in_workspace(0, background_previous_focus); - app.state.focus_pane_in_workspace(0, active_pane); - - let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; - let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); - - let response = app.handle_api_request(crate::api::schema::Request { - id: "req_pane_split_background_tab".into(), - method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: Some(target_pane_id), - direction: crate::api::schema::SplitDirection::Right, - ratio: None, - cwd: None, - focus: false, - right_click: Default::default(), - env: Default::default(), - }), - }); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "pane_info"); - assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); - let response_cwd = - std::path::PathBuf::from(response["result"]["pane"]["cwd"].as_str().unwrap()); - assert_eq!( - crate::worktree::canonical_or_original(&response_cwd), - crate::worktree::canonical_or_original(&split_cwd) - ); - assert_eq!(response["result"]["pane"]["focused"], false); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - active_pane - ); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .pane_count(), - 3 - ); - app.state.last_pane(); - assert_eq!(app.state.workspaces[0].active_tab, background_tab); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - match original_shell { - Some(value) => std::env::set_var("SHELL", value), - None => std::env::remove_var("SHELL"), - } - } - #[tokio::test] async fn pane_split_request_focuses_new_pane_when_requested() { let _guard = config_env_lock().lock().unwrap(); @@ -4977,15 +2909,14 @@ mod tests { let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "confirmation_required"); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 0); + assert_eq!(app.state.selected, 1); assert_eq!(app.state.workspaces.len(), 2); } #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.session_dirty = true; app.sync_session_save_schedule(); @@ -4994,25 +2925,10 @@ mod tests { assert!(app.session_save_deadline.is_some()); } - #[test] - fn next_loop_deadline_includes_session_save_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.session_save_deadline = Some(now + Duration::from_secs(2)); - app.next_resize_poll = now + Duration::from_secs(5); - app.next_auto_update_check = Some(now + Duration::from_secs(6)); - - assert_eq!( - app.next_loop_deadline(now, false), - app.session_save_deadline - ); - } - #[test] fn headless_next_loop_deadline_ignores_resize_poll() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(100); app.session_save_deadline = Some(now + Duration::from_secs(2)); app.next_auto_update_check = Some(now + Duration::from_secs(6)); @@ -5026,7 +2942,6 @@ mod tests { fn headless_next_loop_deadline_returns_none_when_resize_poll_is_only_deadline() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now - Duration::from_millis(1); app.config_diagnostic_deadline = None; app.toast_deadline = None; app.next_auto_update_check = None; @@ -5039,16 +2954,6 @@ mod tests { ); } - #[test] - fn due_session_save_deadline_is_cleared() { - let mut app = test_app(); - app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - - app.handle_scheduled_tasks(Instant::now(), false); - - assert!(app.session_save_deadline.is_none()); - } - #[test] fn due_session_save_starts_background_writer() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); @@ -5057,12 +2962,12 @@ mod tests { std::env::remove_var(crate::session::SESSION_ENV_VAR); let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.workspaces = vec![Workspace::test_new("autosave")]; app.state.ensure_test_terminals(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now(), false); + app.start_background_session_save(); assert!(app.session_save_thread.is_some()); assert!(app.session_save_deadline.is_none()); @@ -5076,7 +2981,7 @@ mod tests { #[test] fn background_session_save_reschedules_when_writer_is_busy() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; let (release_tx, release_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { let _ = release_rx.recv(); @@ -5088,14 +2993,14 @@ mod tests { assert!(app.session_save_deadline.is_some()); release_tx.send(()).unwrap(); - app.no_session = true; + app.policy.persist_session = false; app.save_session_now(); } #[test] fn final_session_save_joins_background_writer_before_returning() { let mut app = test_app(); - app.no_session = true; + app.policy.persist_session = false; let (release_tx, release_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { @@ -5113,52 +3018,6 @@ mod tests { done_rx.try_recv().unwrap(); assert!(app.session_save_thread.is_none()); } - - #[test] - fn next_loop_deadline_includes_selection_autoscroll_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(300); - app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); - app.session_save_deadline = Some(now + Duration::from_millis(200)); - assert_eq!( - app.next_loop_deadline(now, false), - app.selection_autoscroll_deadline - ); - } - - #[test] - fn tick_selection_autoscroll_self_heals_when_state_cleared() { - let mut app = test_app(); - let now = Instant::now(); - app.state.selection_autoscroll = None; - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_on_rect_change() { - let mut app = test_app(); - let now = Instant::now(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Set autoscroll with a stale inner_rect that doesn't match pane_infos - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 999, - inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); @@ -5239,1368 +3098,4 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } - - #[test] - fn route_client_input_dispatches_navigate_mode_keybinds() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - - // Send Ctrl+B then Esc (prefix → leave navigate mode). - // Ctrl+B is 0x02 in raw terminal input. - // After entering navigate mode and pressing Esc, we should leave navigate mode. - let esc_bytes = vec![0x1b]; // Esc - app.route_client_input(esc_bytes); - // Esc in navigate mode should leave navigate mode. - assert_eq!( - app.state.mode, - Mode::Terminal, - "Esc should leave navigate mode and return to Terminal mode" - ); - } - - #[test] - fn explicit_text_commit_does_not_trigger_navigate_binding() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - app.state.mode = Mode::Navigate; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("q"), - )], - false, - ); - - assert!(!app.state.detach_requested); - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.input_leases.is_empty()); - } - - #[test] - fn route_client_input_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - assert!(!app.state.detach_requested); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in terminal mode (default after workspace creation). - app.state.mode = Mode::Terminal; - assert!(!app.state.detach_requested); - - // Send Ctrl+B (prefix key, raw byte 0x02). - let prefix_bytes = vec![0x02]; - app.route_client_input(prefix_bytes); - - assert_eq!( - app.state.mode, - Mode::Prefix, - "prefix key should enter prefix mode" - ); - assert!( - !app.state.detach_requested, - "prefix key should not set detach flag" - ); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_tab_dispatches_global_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut app = test_app(); - let mut first = Workspace::test_new("one"); - let first_second_tab = first.test_add_tab(Some("logs")); - let first_second_root = first.tabs[first_second_tab].root_pane; - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Terminal; - app.state.switch_workspace_tab(0, first_second_tab); - app.state.switch_workspace_tab(1, 0); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[tokio::test] - async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('l'); - app.state.prefix_mods = KeyModifiers::CONTROL; - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Prefix); - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![0x0c])); - } - - #[tokio::test] - async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's - // negotiated encoding instead of being forwarded verbatim. - app.route_client_input(b"\x1b[99;5u".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); - - // iTerm2 and rxvt-style hosts may send F4 as CSI 14~. Normalize it - // through the same semantic key path instead of leaking host bytes. - app.route_client_input(b"\x1b[14~".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1bOS") - ); - } - - #[tokio::test] - async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>4;2m\x1b[=3;1u", - 4, - ); - assert_eq!( - runtime.keyboard_protocol(), - crate::input::KeyboardProtocol::Kitty { flags: 3 } - ); - assert!(runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap(); - runtime.test_process_pty_bytes(b"\x1b[>4;0m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - - #[cfg(unix)] - { - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - } - } - - #[tokio::test] - async fn host_report_all_follows_terminal_protocol_and_command_modes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[15u"); - let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let (other_runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); - app.state.workspaces[0].tabs[0] - .runtimes - .insert(other_pane, other_runtime); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); - assert!(!app.host_keyboard_report_all_requested()); - - assert!(app.state.focus_pane_in_workspace(0, focused)); - app.state.mode = Mode::Prefix; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::Navigate; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::RenameWorkspace; - assert!(!app.host_keyboard_report_all_requested()); - } - - #[tokio::test] - async fn route_client_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("你🙂"), - )], - false, - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input("你".as_bytes().to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你".as_bytes()) - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[32;;20320:22909u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你好".as_bytes()) - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"A".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_text_does_not_erase_owned_physical_key() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - app.route_client_input(b"j".to_vec()); - app.route_client_input(b"\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.try_recv().expect("physical press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("committed text"), - bytes::Bytes::from_static(b"j") - ); - assert_eq!( - rx.try_recv().expect("physical release"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn outer_focus_loss_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![ - raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - ), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on focus loss"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn disconnected_input_source_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 42, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.clear_input_source(42); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on disconnect"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let roundtrip = |events: Vec| { - let message = crate::protocol::ClientMessage::InputEvents { events }; - let encoded = - bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect() - }; - let event = |kind, record| crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }; - - app.route_client_events( - roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - roundtrip(vec![ - event(crate::protocol::ClientKeyKind::Press, record), - event(crate::protocol::ClientKeyKind::Repeat, record), - event( - crate::protocol::ClientKeyKind::Release, - crate::input::WindowsKeyRecord { - key_down: false, - unicode: 0, - ..record - }, - ), - ]), - false, - ); - - for expected in [ - b"\x1b[97;1:1u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:3u".as_slice(), - ] { - assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); - } - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { - let mut app = test_app(); - let workspace = Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().unwrap(); - let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - app.terminal_runtimes.insert(terminal_id.clone(), runtime); - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 3, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let message = crate::protocol::ClientMessage::InputEvents { - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }], - }; - let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - - app.route_client_events( - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect(), - false, - ); - app.shutdown_terminal_runtime(terminal_id.clone()); - - assert_eq!( - rx.try_recv().expect("grouped press"), - bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") - ); - assert_eq!( - rx.try_recv() - .expect("synthetic release before runtime shutdown"), - bytes::Bytes::from_static(b"\x1b[97;1:3u") - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - } - - #[tokio::test] - async fn report_all_repeat_and_release_return_to_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn report_all_key_ownership_is_isolated_by_client() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let first_pane = workspace.focused_pane_id().unwrap(); - let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first_pane); - let (first_runtime, mut first_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - let (second_runtime, mut second_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(first_pane, first_runtime); - workspace.tabs[0] - .runtimes - .insert(second_pane, second_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - - for rx in [&mut first_rx, &mut second_rx] { - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - } - - #[tokio::test] - async fn report_all_release_survives_modifier_release_order() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106:74;2:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_release_for_consumed_prefix() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); - - assert_eq!(app.state.mode, Mode::Prefix); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[13;2u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[27;2;13~") - ); - } - - #[tokio::test] - async fn route_client_input_splits_multi_event_payloads_before_forwarding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"ab".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "中日한🙂"; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in text.chars() { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); - let char_count = text.chars().count(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in 0..char_count { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_handles_mouse_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Send a mouse scroll-up event via SGR encoding. - let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); - // This should not panic even though mouse handling is simplified - // in headless mode. - app.route_client_input(mouse_bytes); - // No assertions on specific behavior — just no panic. - } - - #[test] - fn route_client_input_advances_onboarding_modal() { - let mut app = test_app(); - app.state.mode = Mode::Onboarding; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.mode, Mode::Settings); - assert_eq!( - app.state.settings.section, - state::SettingsSection::Integrations - ); - } - - #[test] - fn route_client_input_pastes_bracketed_text_into_rename_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.name_input = "2".into(); - app.state.name_input_replace_on_type = true; - - app.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec()); - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[test] - fn route_client_input_rename_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn route_client_input_context_menu_enter_submits_through_api_path() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(state::ContextMenuState { - kind: state::ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: state::MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[test] - fn raw_ctrl_v_decodes_as_modal_paste_shortcut() { - let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]); - let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else { - panic!("expected ctrl-v key event"); - }; - - assert!(input::is_modal_paste_shortcut(&key.as_key_event())); - } - - #[test] - fn route_client_events_pastes_text_into_new_linked_worktree_modal() { - let mut app = test_app(); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(state::WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste( - "feature/linear-302".into(), - )], - true, - ); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[tokio::test] - async fn route_client_events_pastes_only_into_popup() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - assert!(app - .state - .should_capture_host_mouse_from(&app.terminal_runtimes)); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())], - true, - ); - - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"popup-only") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.route_client_events( - vec![raw_key( - KeyCode::Char('x'), - KeyModifiers::NONE, - KeyEventKind::Press, - )], - true, - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"x") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.state.mode = Mode::Settings; - assert!( - app.handle_raw_input_event(raw_key( - KeyCode::Char('y'), - KeyModifiers::NONE, - KeyEventKind::Repeat, - )) - .await - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"y") - ); - assert!(tiled_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_events_discards_paste_when_popup_runtime_is_missing() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let install_missing_popup = |app: &mut App| { - let popup_terminal_id = crate::terminal::TerminalId::alloc(); - app.state.terminals.insert( - popup_terminal_id.clone(), - crate::terminal::TerminalState::new( - popup_terminal_id.clone(), - std::path::PathBuf::from("/popup"), - ), - ); - app.state.popup_pane = Some(state::PopupPaneState { - pane_id: crate::layout::PaneId::alloc(), - terminal_id: popup_terminal_id, - width: None, - height: None, - }); - }; - install_missing_popup(&mut app); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())], - true, - ); - - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - - install_missing_popup(&mut app); - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste( - "discard-monolithic".into(), - )) - .await - ); - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - } - - #[tokio::test] - async fn popup_mouse_motion_preserves_scrollback() { - let mut app = test_app(); - app.state.mode = Mode::Terminal; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h", - 4, - ); - popup_runtime.scroll_up(1); - assert!(popup_runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(app - .popup_runtime() - .and_then(TerminalRuntime::scroll_metrics) - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - } - - #[tokio::test] - async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )) - .await - ); - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_closes_release_notes_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.release_notes.is_none()); - } - - #[test] - fn route_client_input_closes_settings_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Settings; - app.state.settings.original_theme = Some(app.state.theme_name.clone()); - app.state.settings.original_palette = Some(app.state.palette.clone()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn route_client_input_updates_host_terminal_theme_from_osc_response() { - let mut app = test_app(); - - app.route_client_input(b"\x1b]11;#123456\x07\x1b]4;7;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()); - - assert_eq!( - app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(b"\x1b]4;7;rgb:dddd/eeee/ffff\x1b\\".to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }) - ); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_incomplete_osc_introducer_to_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b]".to_vec()); - - assert!(rx.try_recv().is_err()); - } - - #[test] - fn parse_raw_input_bytes_with_ranges_tracks_offsets() { - // Verify that the range-aware parser correctly tracks byte offsets - // for events within a multi-event input buffer. - let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a' - let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(events.len(), 2, "should parse Up arrow and 'a'"); - // Up arrow: \x1b[A = 3 bytes starting at offset 0 - assert_eq!(events[0].start, 0); - assert_eq!(events[0].len, 3); - // 'a': 1 byte starting at offset 3 - assert_eq!(events[1].start, 3); - assert_eq!(events[1].len, 1); - - // Verify the raw bytes for each event are correct. - assert_eq!( - &input[events[0].start..events[0].start + events[0].len], - b"\x1b[A" - ); - assert_eq!( - &input[events[1].start..events[1].start + events[1].len], - b"a" - ); - } } diff --git a/src/app/pane_graphics.rs b/src/app/pane_graphics.rs index c1d4841a..7b516116 100644 --- a/src/app/pane_graphics.rs +++ b/src/app/pane_graphics.rs @@ -31,6 +31,7 @@ pub(crate) struct Layer { #[derive(Debug)] pub(crate) struct DirectGate { pub(crate) transfer_id: u64, + pub(crate) image_id: u32, pub(crate) client_id: u64, pub(crate) deadline: std::time::Instant, pub(crate) written: bool, diff --git a/src/app/popup.rs b/src/app/popup.rs index c5877987..8bcd8ae3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -37,18 +37,6 @@ impl App { true } - pub(crate) fn try_route_paste_to_popup(&mut self, text: &str) -> bool { - if self.state.popup_pane.is_none() { - return false; - } - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return true; - }; - let _ = runtime.try_send_paste(text.to_owned()); - true - } - pub(crate) fn spawn_popup_shell_command( &mut self, command: &str, @@ -218,7 +206,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 8367a223..99769e7b 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -3,11 +3,8 @@ use std::time::Instant; #[cfg(test)] use std::time::Duration; -use crossterm::terminal; - use super::{ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, - RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; fn retain_detached_process_after_wait( pid: u32, @@ -31,10 +28,6 @@ impl App { } pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) { - let target = super::TerminalInputTarget { - terminal_id: terminal_id.clone(), - }; - self.release_input_target_headless(&target); if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { runtime.shutdown(); } @@ -47,377 +40,6 @@ impl App { } } - pub(crate) fn drain_api_requests(&mut self) -> bool { - let mut changed = false; - while let Ok(msg) = self.api_rx.try_recv() { - changed |= self.handle_api_request_message(msg); - self.shutdown_detached_terminal_runtimes(); - } - changed - } - - pub(super) fn handle_api_request_message( - &mut self, - msg: crate::api::ApiRequestMessage, - ) -> bool { - let previous_mode = self.state.mode; - let stream_open = match &msg.request.method { - crate::api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()), - _ => None, - }; - let stream_active = msg.stream_active.clone(); - let mut changed = self.expire_due_metadata(Instant::now()); - changed |= crate::api::request_changes_ui(&msg.request); - let skip_default_workspace = matches!( - &msg.request.method, - crate::api::schema::Method::ServerStop(_) - | crate::api::schema::Method::ServerLiveHandoff(_) - ); - if matches!( - &msg.request.method, - crate::api::schema::Method::WorktreeCreate(_) - | crate::api::schema::Method::WorktreeRemove(_) - ) { - self.drain_all_internal_events(); - let deferred_changed = - self.handle_deferred_worktree_api_request(msg.request, msg.respond_to); - if !skip_default_workspace { - changed |= self.ensure_default_workspace(); - } - self.sync_prefix_input_source(previous_mode); - return changed | deferred_changed; - } - let response = self.handle_api_request(msg.request); - if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) { - self.attach_pane_graphics_stream_active(params, active, &response); - } - if !skip_default_workspace { - changed |= self.ensure_default_workspace(); - } - let _ = msg.respond_to.send(response); - self.sync_prefix_input_source(previous_mode); - changed - } - - pub(super) async fn handle_raw_input_batch( - &mut self, - first: crate::raw_input::RawInputEvent, - ) -> bool { - let mut changed = self.handle_raw_input_event(first).await; - - while let Some(rx) = self.input_rx.as_mut() { - match rx.try_recv() { - Ok(event) => changed |= self.handle_raw_input_event(event).await, - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - self.input_rx = None; - break; - } - } - } - - changed - } - - async fn execute_repeat_plan( - &mut self, - lease_key: super::input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: super::input::RepeatPlan, - ) -> bool { - match plan { - super::input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target(&target, key).await { - self.input_leases.remove(&lease_key); - } - true - } - super::input::RepeatPlan::Reprocess { - context, - repetitions, - tracked, - } => { - let key = key - .with_kind(crossterm::event::KeyEventKind::Repeat) - .with_repeat_count(1); - let mut forwarded_target = None; - for _ in 0..repetitions { - if let Some(target) = &forwarded_target { - if !self - .forward_terminal_key_to_target(target, key.clone()) - .await - { - self.input_leases.remove(&lease_key); - break; - } - continue; - } - let current_context = self.terminal_input_context(); - if !self.input_leases.reprocess_allowed( - lease_key, - &context, - current_context.as_ref(), - tracked, - ) { - break; - } - if let Some(target) = self.handle_key(key.clone()).await { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - true - } - super::input::RepeatPlan::Ignore => false, - } - } - - pub(super) async fn handle_raw_input_event( - &mut self, - event: crate::raw_input::RawInputEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key); - let key = self.input_leases.normalize_press(&lease_key, key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = self.handle_key(key.clone()).await; - let resulting_context = self.terminal_input_context(); - let plan = self.input_leases.complete_press( - lease_key, - &key, - initial_context.as_ref(), - resulting_context.as_ref(), - target, - ); - self.execute_repeat_plan(lease_key, key, plan).await; - true - } - crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); - let plan = self.input_leases.plan_repeat( - lease_key, - &key, - current_context.as_ref(), - ); - self.execute_repeat_plan(lease_key, key, plan).await - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target(&lease.target, key) - .await; - } - false - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit(text.into_string()).await; - true - } - crate::raw_input::RawInputEvent::Paste(text) => { - self.handle_paste(text).await; - true - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let changes_view = !matches!(mouse.kind, crossterm::event::MouseEventKind::Moved) - || self.state.mode.mouse_motion_changes_view(); - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse(mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - changes_view - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - #[cfg(not(windows))] - self.query_host_terminal_appearance(); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - if self.state.redraw_on_focus_gained { - self.request_repaint(); - } - self.state.outer_terminal_focus = Some(true); - self.state.mark_active_tab_seen(); - true - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source(super::LOCAL_INPUT_SOURCE).await; - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - self.state.outer_terminal_focus = Some(false); - false - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - self.update_host_terminal_theme(kind, color) - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - self.update_host_terminal_palette_colors(&colors) - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - self.query_host_terminal_theme(); - self.set_host_terminal_appearance(appearance, true) - } - // Cell size reports are consumed by the thin client, not the runtime. - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => false, - crate::raw_input::RawInputEvent::Unsupported => false, - }; - self.sync_prefix_input_source(previous_mode); - self.shutdown_detached_terminal_runtimes(); - changed - } - - fn handle_resize_poll(&mut self) -> bool { - let Ok(size) = terminal::size() else { - return false; - }; - if self.last_terminal_size != Some(size) { - self.last_terminal_size = Some(size); - return true; - } - false - } - - pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool { - let mut changed = false; - let mut resized = false; - - if now >= self.next_resize_poll { - resized = self.handle_resize_poll(); - changed |= resized; - self.next_resize_poll = now + RESIZE_POLL_INTERVAL; - } - - if self - .config_diagnostic_deadline - .is_some_and(|deadline| now >= deadline) - { - self.config_diagnostic_deadline = None; - self.state.config_diagnostic = None; - changed = true; - } - - if self.toast_deadline.is_some_and(|deadline| now >= deadline) { - self.toast_deadline = None; - self.state.toast = None; - changed = true; - } - - if self - .state - .next_pending_agent_notification_deadline() - .is_some_and(|deadline| now >= deadline) - { - let previous_toast = self.state.toast.clone(); - let mut deliveries = self.state.drain_due_agent_notifications(now); - if !deliveries.is_empty() { - self.refresh_agent_notification_delivery_contexts(&mut deliveries); - self.emit_delayed_client_local_agent_notifications(&deliveries); - self.sync_toast_deadline(previous_toast); - changed = true; - } - } - - if self - .state - .next_managed_agent_deadline() - .is_some_and(|deadline| now >= deadline) - { - let panes = self.state.reconcile_managed_agents_at(now); - if !panes.is_empty() { - for (ws_idx, pane_id) in panes { - self.emit_pane_updated(ws_idx, pane_id); - } - self.schedule_session_save(); - changed = true; - } - } - - if self - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.copy_feedback_deadline = None; - self.state.copy_feedback = None; - changed = true; - } - - if self - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.clear_due_selection_highlight(now); - - self.start_git_status_refresh_if_due(now); - - if self - .next_auto_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_auto_update_check(); - } - - if self - .next_agent_manifest_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_agent_manifest_update_check(); - } - - if self - .session_save_deadline - .is_some_and(|deadline| now >= deadline) - { - self.start_background_session_save(); - } - - changed |= self.expire_due_metadata(now); - changed |= self.handle_tab_bar_status_tasks(now); - - if geometry_dirty || resized { - self.pending_agent_resume_deadline = None; - } else { - self.sync_pending_agent_resume_deadline(now); - changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now)); - } - changed - } - - /// Clears temporary copied-token highlights, such as after double-click copy. - pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool { - if self - .selection_highlight_clear_deadline - .is_none_or(|deadline| now < deadline) - { - return false; - } - - self.selection_highlight_clear_deadline = None; - if self - .state - .selection - .as_ref() - .is_some_and(|selection| !selection.is_in_progress()) - { - self.state.clear_selection(); - return true; - } - false - } - pub(crate) fn sync_agent_metadata_deadline(&mut self) { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } @@ -449,84 +71,6 @@ impl App { self.sync_agent_metadata_deadline(); } - pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) { - let Some(autoscroll) = self.state.selection_autoscroll.clone() else { - // Self-heal: state cleared but deadline leaked - self.selection_autoscroll_deadline = None; - return; - }; - - // Selection must still be in progress for autoscroll to continue - let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else { - self.stop_selection_autoscroll(); - return; - }; - if !self - .state - .selection - .as_ref() - .is_some_and(|s| s.is_dragging()) - { - self.stop_selection_autoscroll(); - return; - } - - // Rect-change detection: if inner_rect changed since drag, stop - let current_rect = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect); - if current_rect != Some(autoscroll.inner_rect) { - self.stop_selection_autoscroll(); - return; - } - - // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable - let Some(metrics) = self - .state - .pane_scroll_metrics(&self.terminal_runtimes, pane_id) - else { - self.stop_selection_autoscroll(); - return; - }; - match autoscroll.direction { - crate::app::state::SelectionAutoscrollDirection::Up => { - let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom; - if at_top { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, 1); - } - crate::app::state::SelectionAutoscrollDirection::Down => { - let at_bottom = metrics.offset_from_bottom == 0; - if at_bottom { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, 1); - } - } - - // Extend selection cursor to last known mouse position - self.state.update_selection_cursor( - &self.terminal_runtimes, - pane_id, - autoscroll.last_mouse_screen_col, - autoscroll.last_mouse_screen_row, - ); - - // Reschedule - self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); - } - - pub(crate) fn stop_selection_autoscroll(&mut self) { - self.state.stop_selection_autoscroll_state(); - self.selection_autoscroll_deadline = None; - } - pub(crate) fn can_render_now(&self, now: Instant) -> bool { match self.last_render_at { Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, @@ -551,7 +95,10 @@ impl App { } pub(crate) fn run_auto_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_version_check_enabled, + ) { self.next_auto_update_check = None; return; } @@ -571,7 +118,10 @@ impl App { } pub(crate) fn run_agent_manifest_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_manifest_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_manifest_check_enabled, + ) { self.next_agent_manifest_update_check = None; return; } @@ -582,25 +132,11 @@ impl App { std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx)); } - pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option { - self.next_loop_deadline_with_resize_poll(now, needs_render, true, true) - } - pub(crate) fn next_headless_loop_deadline_with_git_refresh( &self, now: Instant, needs_render: bool, include_git_refresh: bool, - ) -> Option { - self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh) - } - - fn next_loop_deadline_with_resize_poll( - &self, - now: Instant, - needs_render: bool, - include_resize_poll: bool, - include_git_refresh: bool, ) -> Option { let render_deadline = if needs_render { self.last_render_at @@ -611,12 +147,10 @@ impl App { }; [ - include_resize_poll.then_some(self.next_resize_poll), self.config_diagnostic_deadline, self.toast_deadline, self.state.next_pending_agent_notification_deadline(), self.state.next_managed_agent_deadline(), - self.copy_feedback_deadline, include_git_refresh .then(|| self.git_refresh_deadline()) .flatten(), @@ -625,8 +159,6 @@ impl App { self.agent_metadata_deadline, self.pending_agent_resume_deadline, self.session_save_deadline, - self.selection_autoscroll_deadline, - self.selection_highlight_clear_deadline, self.next_tab_bar_status_deadline(), render_deadline, ] @@ -635,11 +167,13 @@ impl App { .min() } + #[cfg(test)] pub(crate) fn drain_internal_events(&mut self) -> bool { self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT) .1 } + #[cfg(test)] pub(crate) fn drain_all_internal_events(&mut self) -> bool { let mut changed = false; loop { @@ -653,6 +187,7 @@ impl App { changed } + #[cfg(test)] fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) { let mut had_event = false; let mut changed = false; @@ -661,7 +196,7 @@ impl App { break; }; had_event = true; - changed |= self.handle_internal_event_with_prefix_sync(ev); + changed |= self.handle_internal_event_with_render_impact(ev); } (had_event, changed) } @@ -670,7 +205,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::app::state; use crate::workspace::Workspace; #[test] @@ -697,7 +231,7 @@ mod tests { fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { let mut app = super::super::App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), @@ -716,264 +250,4 @@ mod tests { }); (app, pane_id) } - - #[test] - fn tick_selection_autoscroll_stops_when_metrics_unavailable() { - // Without a runtime, pane_scroll_metrics returns None. - // Fail-closed: stop autoscroll instead of rescheduling forever. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes Dragging - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // Should stop because no runtime metrics available - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_done() { - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - // Create a selection that is already finished (not in progress) - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes visible, then finish - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - sel.finish(); // now it's Done, not in progress - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_cleared() { - let (mut app, _pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = None; - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_anchored() { - // Anchored (click, no drag) should not keep the timer running. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - /// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics - /// returns meaningful data. Uses test_with_scrollback_bytes. - fn test_app_with_runtime( - cols: u16, - rows: u16, - bytes: &[u8], - ) -> (super::super::App, crate::layout::PaneId) { - let mut app = super::super::App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), - ); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.view.pane_infos.push(crate::layout::PaneInfo { - id: pane_id, - rect: ratatui::layout::Rect::new(0, 0, cols, rows), - inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (app, pane_id) - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_top() { - // Create a runtime with no scrollback content — we're already at - // the top (offset_from_bottom == max_offset_from_bottom). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None); - sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Up, - last_mouse_screen_col: 0, - last_mouse_screen_row: 0, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback top, can't scroll further up — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_bottom() { - // Create a runtime with no scrollback content — we're already at - // the bottom (offset_from_bottom == 0). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback bottom, can't scroll further down — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[tokio::test] - async fn passive_mouse_motion_does_not_request_monolithic_render() { - let (mut app, _) = test_app_with_pane(); - app.state.mode = crate::app::Mode::Terminal; - let motion = || { - crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: 10, - row: 5, - modifiers: crossterm::event::KeyModifiers::empty(), - }) - }; - - assert!(!app.handle_raw_input_event(motion()).await); - app.state.mode = crate::app::Mode::GlobalMenu; - assert!(app.handle_raw_input_event(motion()).await); - } - - #[tokio::test] - async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { - let (mut app, pane_id) = test_app_with_pane(); - app.state.ensure_test_terminals(); - let terminal_id = app.state.workspaces[0] - .terminal_id(pane_id) - .cloned() - .expect("test pane should have a terminal"); - app.state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - - assert!( - app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor { - kind: crate::terminal_theme::DefaultColorKind::Foreground, - color: crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }, - }) - .await - ); - assert!( - app.terminal_runtimes.get(&terminal_id).is_none(), - "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos" - ); - assert!(app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - } - - #[tokio::test] - async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { - let (mut app, pane_id) = test_app_with_pane(); - app.state.ensure_test_terminals(); - app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - let terminal_id = app.state.workspaces[0] - .terminal_id(pane_id) - .cloned() - .expect("test pane should have a terminal"); - app.state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - assert!(!app.handle_scheduled_tasks(Instant::now(), true)); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(app.pending_agent_resume_deadline.is_none()); - } } diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs deleted file mode 100644 index d3d767e7..00000000 --- a/src/app/runtime_mutations.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::api::schema::{ - EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams, - PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, - PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget, - WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, - WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, - WorktreeRemoveParams, -}; - -use super::App; - -impl App { - pub(crate) fn dispatch_runtime_mutation(&mut self, id: &'static str, method: Method) -> String { - self.dispatch_api_request(id, method) - } - - pub(crate) fn dispatch_deferred_runtime_mutation( - &mut self, - id: &'static str, - method: Method, - ) -> Option { - self.dispatch_deferred_api_request(id, method) - } - - pub(crate) fn runtime_workspace_focus( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) - } - - pub(crate) fn runtime_workspace_create( - &mut self, - id: &'static str, - params: WorkspaceCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceCreate(params)) - } - - pub(crate) fn runtime_workspace_rename( - &mut self, - id: &'static str, - params: WorkspaceRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceRename(params)) - } - - pub(crate) fn runtime_workspace_move( - &mut self, - id: &'static str, - params: WorkspaceMoveParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params)) - } - - pub(crate) fn runtime_workspace_move_block( - &mut self, - id: &'static str, - params: WorkspaceMoveBlockParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params)) - } - - pub(crate) fn runtime_workspace_close_group( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation( - id, - Method::WorkspaceClose(WorkspaceCloseParams { - workspace_id, - close_group: true, - }), - ) - } - - pub(crate) fn runtime_tab_create( - &mut self, - id: &'static str, - params: TabCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabCreate(params)) - } - - pub(crate) fn runtime_tab_focus(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabFocus(TabTarget { tab_id })) - } - - pub(crate) fn runtime_tab_rename( - &mut self, - id: &'static str, - params: TabRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabRename(params)) - } - - pub(crate) fn runtime_tab_move(&mut self, id: &'static str, params: TabMoveParams) -> String { - self.dispatch_runtime_mutation(id, Method::TabMove(params)) - } - - pub(crate) fn runtime_tab_close(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabClose(TabTarget { tab_id })) - } - - pub(crate) fn runtime_server_reload_config(&mut self, id: &'static str) -> String { - self.dispatch_runtime_mutation(id, Method::ServerReloadConfig(EmptyParams::default())) - } - - pub(crate) fn runtime_pane_focus(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocus(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_close(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneClose(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_rename( - &mut self, - id: &'static str, - params: PaneRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneRename(params)) - } - - pub(crate) fn runtime_pane_input_set( - &mut self, - id: &'static str, - params: PaneInputSetParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneInputSet(params)) - } - - pub(crate) fn runtime_pane_focus_direction( - &mut self, - id: &'static str, - params: PaneFocusDirectionParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocusDirection(params)) - } - - pub(crate) fn runtime_pane_resize( - &mut self, - id: &'static str, - params: PaneResizeParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneResize(params)) - } - - pub(crate) fn runtime_pane_swap(&mut self, id: &'static str, params: PaneSwapParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSwap(params)) - } - - pub(crate) fn runtime_pane_split( - &mut self, - id: &'static str, - params: PaneSplitParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSplit(params)) - } - - pub(crate) fn runtime_pane_zoom(&mut self, id: &'static str, params: PaneZoomParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneZoom(params)) - } - - pub(crate) fn runtime_layout_set_split_ratio( - &mut self, - id: &'static str, - params: LayoutSetSplitRatioParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::LayoutSetSplitRatio(params)) - } - - pub(crate) fn runtime_worktree_create_deferred( - &mut self, - id: &'static str, - params: WorktreeCreateParams, - ) -> Option { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeCreate(params)) - } - - pub(crate) fn runtime_worktree_open( - &mut self, - id: &'static str, - params: WorktreeOpenParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorktreeOpen(params)) - } - - pub(crate) fn runtime_worktree_remove_deferred( - &mut self, - id: &'static str, - params: WorktreeRemoveParams, - ) -> Option { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeRemove(params)) - } -} diff --git a/src/app/session.rs b/src/app/session.rs index 60d49d52..07bc80a9 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -12,7 +12,7 @@ enum SessionSaveJob { impl App { pub(super) fn schedule_session_save(&mut self) { - if !self.no_session { + if self.policy.persist_session { self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); } } @@ -46,9 +46,6 @@ impl App { &self.terminal_runtimes, self.state.active, self.state.selected, - self.state.sidebar_width, - self.state.sidebar_section_split, - self.state.collapsed_space_keys.clone(), ); let history = self.persist_pane_history.then(|| { crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) @@ -58,7 +55,7 @@ impl App { } pub(crate) fn start_background_session_save(&mut self) { - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } @@ -88,7 +85,7 @@ impl App { let _ = thread.join(); } - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } diff --git a/src/app/state.rs b/src/app/state.rs index 782b8ae5..0ce9fb6d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,13 +1,10 @@ -use crate::config::{ - Keybinds, NewTerminalCwdConfig, SoundConfig, TabBarPositionConfig, ToastConfig, ToastDelivery, -}; +use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig}; use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::layout::{Direction, Rect}; +use ratatui::layout::Rect; use ratatui::style::Color; use crate::detect::AgentState; -use crate::layout::{PaneId, PaneInfo, SplitBorder}; -use crate::selection::Selection; +use crate::layout::{PaneId, PaneInfo}; pub(crate) type InstalledPluginRegistry = std::collections::HashMap; @@ -25,36 +22,6 @@ pub(crate) struct PopupPaneState { pub height: Option, } -// --------------------------------------------------------------------------- -// Selection autoscroll types -// --------------------------------------------------------------------------- - -/// Direction of automatic scrolling during text selection drag. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SelectionAutoscrollDirection { - Up, - Down, -} - -/// State for automatic scrolling during text selection drag. -/// -/// When the cursor hovers in the 1-row hot zone at the top or bottom edge -/// of a pane (or outside the pane), this struct captures the direction and -/// last known mouse position so a recurring 30ms tick can continue scrolling -/// and extending the selection even when the mouse is not moving. -#[derive(Clone, Debug)] -pub(crate) struct SelectionAutoscroll { - pub direction: SelectionAutoscrollDirection, - pub last_mouse_screen_col: u16, - pub last_mouse_screen_row: u16, - pub inner_rect: Rect, -} - -#[derive(Clone)] -pub(crate) struct RightClickPassthroughGesture { - pub pane_info: PaneInfo, - pub modifiers: KeyModifiers, -} use crate::terminal_theme::{HostAppearance, TerminalTheme}; use crate::workspace::Workspace; @@ -65,7 +32,6 @@ use crate::workspace::Workspace; /// All colors used by the UI. Derived from a base accent color for now, /// but structured so a full theme system can replace it later. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // all fields defined for theming — some used later pub struct Palette { /// Primary accent (highlight, active borders). pub accent: Color, @@ -709,364 +675,16 @@ impl Palette { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WorkspaceCardArea { - pub ws_idx: usize, - pub rect: Rect, - pub indented: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeCreateState { - pub source_workspace_id: String, - pub source_checkout_path: std::path::PathBuf, - pub source_existing_membership: Option, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub branch: String, - pub checkout_path: std::path::PathBuf, - pub error: Option, - pub creating: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeRemoveState { - pub workspace_id: String, - pub repo_root: std::path::PathBuf, - pub path: std::path::PathBuf, - pub error: Option, - pub removing: bool, - pub force_confirmation: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenEntry { - pub path: std::path::PathBuf, - pub branch: Option, - pub is_linked_worktree: bool, - pub already_open_ws_idx: Option, -} - -impl WorktreeOpenEntry { - pub(crate) fn display_name(&self) -> String { - self.branch.clone().unwrap_or_else(|| { - self.path - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_owned) - .unwrap_or_else(|| self.path.display().to_string()) - }) - } - - pub(crate) fn status_label(&self) -> &'static str { - if self.already_open_ws_idx.is_some() { - "open" - } else if self.branch.is_some() { - "" - } else if self.is_linked_worktree { - "detached" - } else { - "root" - } - } - - fn search_text(&self) -> String { - format!( - "{} {} {} {}", - self.display_name(), - self.path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(), - self.path.display(), - self.status_label() - ) - .to_lowercase() - } - - fn matches_query(&self, query: &str) -> bool { - text_matches_query(query, &self.search_text()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenState { - pub source_workspace_id: String, - pub source_existing_membership: Option, - pub source_checkout_path: std::path::PathBuf, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub entries: Vec, - pub selected: usize, - pub query: String, - pub search_focused: bool, - pub error: Option, -} - -impl WorktreeOpenState { - pub(crate) fn filtered_indices(&self) -> Vec { - let query = self.query.trim(); - self.entries - .iter() - .enumerate() - .filter_map(|(idx, entry)| { - (query.is_empty() || entry.matches_query(query)).then_some(idx) - }) - .collect() - } - - pub(crate) fn selected_entry_index(&self) -> Option { - let indices = self.filtered_indices(); - if indices.contains(&self.selected) { - Some(self.selected) - } else { - indices.first().copied() - } - } - - pub(crate) fn normalize_selection(&mut self) { - if let Some(selected) = self.selected_entry_index() { - self.selected = selected; - } - } - - pub(crate) fn select_previous_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[pos.saturating_sub(1)]; - } - - pub(crate) fn select_next_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[(pos + 1).min(indices.len().saturating_sub(1))]; - } -} - -pub(crate) fn text_matches_query(query: &str, text: &str) -> bool { - let haystack = text.to_lowercase(); - query - .to_lowercase() - .split_whitespace() - .all(|needle| haystack.contains(needle)) -} - -/// Computed view geometry — derived from AppState + terminal size. -/// Updated before each render, consumed by render and mouse handling. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewLayout { - Desktop, - Mobile, -} - +/// Geometry for the server-rendered active-tab pane surface. pub struct ViewState { - pub layout: ViewLayout, - pub sidebar_rect: Rect, - pub workspace_card_areas: Vec, - pub tab_bar_rect: Rect, - pub tab_hit_areas: Vec, - pub tab_scroll_left_hit_area: Rect, - pub tab_scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, pub terminal_area: Rect, - pub mobile_header_rect: Rect, - pub mobile_menu_hit_area: Rect, - pub toast_hit_area: Rect, pub pane_infos: Vec, - pub split_borders: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { - Onboarding, - ReleaseNotes, - ProductAnnouncement, Navigate, - Prefix, - Copy, Terminal, - RenameWorkspace, - RenameTab, - RenamePane, - NewLinkedWorktree, - OpenExistingWorktree, - ConfirmRemoveWorktree, - Resize, - ConfirmClose, - ContextMenu, - Settings, - GlobalMenu, - KeybindHelp, - Navigator, -} - -impl Mode { - pub(crate) fn mouse_motion_changes_view(self) -> bool { - matches!(self, Self::GlobalMenu | Self::ContextMenu | Self::Navigator) - } - - /// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather - /// than free text. This is an explicit **allowlist** of the prefix command/navigation realm: - /// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a - /// new text-entry or overlay mode can never silently force ASCII. Used by - /// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level - /// prefix commands keep ASCII until they return to the terminal. - /// - /// Known limitation: the search boxes in `Navigator` and `KeybindHelp` are also held on ASCII, - /// since this `Mode`-level predicate can't see `search_focused` (non-ASCII filtering there - /// would need a runtime check). - pub(crate) fn wants_ascii_input(self) -> bool { - matches!( - self, - Mode::Prefix - | Mode::Navigate - | Mode::Navigator - | Mode::Copy - | Mode::Resize - | Mode::ConfirmClose - | Mode::ConfirmRemoveWorktree - | Mode::ContextMenu - | Mode::GlobalMenu - | Mode::KeybindHelp - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum NavigatorTarget { - Workspace { - ws_idx: usize, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct NavigatorRow { - pub target: NavigatorTarget, - pub depth: u8, - pub label: String, - pub meta: String, - pub status: AgentState, - pub seen: bool, - pub is_current: bool, - pub is_workspace: bool, - pub is_tab: bool, - pub expanded: bool, - pub search_text: String, - /// Whether this row itself matched the active query/state filter, as - /// opposed to being included as ancestor context or cascaded subtree of a - /// matching workspace or tab. Always true when no filter is active. - pub matched: bool, -} - -/// One rendered line in the navigator body. Spacer lines separate workspace -/// groups visually and are not selectable. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorDisplayLine { - Spacer, - Row(usize), -} - -pub(crate) fn navigator_display_lines(rows: &[NavigatorRow]) -> Vec { - let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); - for (idx, row) in rows.iter().enumerate() { - if row.is_workspace && !lines.is_empty() { - lines.push(NavigatorDisplayLine::Spacer); - } - lines.push(NavigatorDisplayLine::Row(idx)); - } - lines -} - -pub(crate) fn navigator_display_index_of_row( - lines: &[NavigatorDisplayLine], - row_idx: usize, -) -> Option { - lines - .iter() - .position(|line| *line == NavigatorDisplayLine::Row(row_idx)) -} - -pub(crate) fn navigator_first_row_at_or_after( - lines: &[NavigatorDisplayLine], - line_idx: usize, -) -> Option { - lines.get(line_idx..)?.iter().find_map(|line| match line { - NavigatorDisplayLine::Row(idx) => Some(*idx), - NavigatorDisplayLine::Spacer => None, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorStateFilter { - Blocked, - Working, - Idle, - Done, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct NavigatorState { - pub query: String, - pub selected: usize, - pub scroll: usize, - pub search_focused: bool, - pub state_filter: Option, - pub expanded_workspaces: std::collections::HashSet, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeState { - pub pane_id: PaneId, - pub cursor_row: u16, - pub cursor_col: u16, - pub entry_offset_from_bottom: usize, - pub selection: Option, - pub search: CopyModeSearchState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSelection { - Character, - Linewise { anchor_row: u32 }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSearchDirection { - Forward, - Backward, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeSearchPrompt { - pub direction: CopyModeSearchDirection, - pub query: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct CopyModeSearchState { - pub prompt: Option, - pub query: String, - pub direction: Option, - pub matches: Vec, - pub current: Option, - pub geometry: Option<(u16, u16)>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -1076,98 +694,6 @@ pub enum AgentPanelSort { Priority, } -// --------------------------------------------------------------------------- -// Settings UI state -// --------------------------------------------------------------------------- - -/// Which section of the settings panel is focused. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsSection { - Theme, - Indicators, - Sound, - Toast, - PaneLabels, - Integrations, -} - -impl SettingsSection { - pub const ALL: &[Self] = &[ - Self::Theme, - Self::Indicators, - Self::Sound, - Self::Toast, - Self::PaneLabels, - Self::Integrations, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Theme => "theme", - Self::Indicators => "indicators", - Self::Sound => "sound", - Self::Toast => "toasts", - Self::PaneLabels => "pane labels", - Self::Integrations => "integrations", - } - } -} - -/// All built-in theme names in display order. -pub const THEME_NAMES: &[&str] = crate::config::THEME_NAMES; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MenuListState { - pub highlighted: usize, -} - -impl MenuListState { - pub fn new(highlighted: usize) -> Self { - Self { highlighted } - } - - pub fn move_prev(&mut self) { - self.highlighted = self.highlighted.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.highlighted = (self.highlighted + 1).min(item_count - 1); - } - } - - pub fn hover(&mut self, idx: Option) { - if let Some(idx) = idx { - self.highlighted = idx; - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SelectionListState { - pub selected: usize, -} - -impl SelectionListState { - pub fn new(selected: usize) -> Self { - Self { selected } - } - - pub fn move_prev(&mut self) { - self.selected = self.selected.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.selected = (self.selected + 1).min(item_count - 1); - } - } - - pub fn select(&mut self, idx: usize) { - self.selected = idx; - } -} - #[derive(Debug, Clone)] pub struct ThemeRuntimeConfig { pub manual_name: String, @@ -1178,167 +704,6 @@ pub struct ThemeRuntimeConfig { pub legacy_accent: Option, } -pub struct SettingsState { - /// Which section tab is active. - pub section: SettingsSection, - /// Selected item index within the current section. - pub list: SelectionListState, - /// The palette before opening settings (for cancel/restore). - pub original_palette: Option, - /// The theme name before opening settings. - pub original_theme: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkspaceDropTarget { - Before(usize), - End, -} - -pub(crate) enum DragTarget { - WorkspaceReorder { - source_id: crate::app::InputSourceId, - source_ws_idx: usize, - drop_target: Option, - }, - TabReorder { - source_id: crate::app::InputSourceId, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: Option, - }, - WorkspaceListScrollbar { - grab_row_offset: u16, - }, - AgentPanelScrollbar { - grab_row_offset: u16, - }, - PaneSplit { - path: Vec, - direction: Direction, - area: Rect, - grab_offset: u16, - }, - PaneScrollbar { - pane_id: crate::layout::PaneId, - grab_row_offset: u16, - }, - ReleaseNotesScrollbar { - grab_row_offset: u16, - }, - ProductAnnouncementScrollbar { - grab_row_offset: u16, - }, - KeybindHelpScrollbar { - grab_row_offset: u16, - }, - SidebarDivider, - SidebarSectionDivider, -} - -/// Active mouse drag on a split border or sidebar divider. -pub(crate) struct DragState { - pub target: DragTarget, -} - -pub(crate) struct WorkspacePressState { - pub ws_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -pub(crate) struct TabPressState { - pub ws_idx: usize, - pub tab_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContextMenuKind { - Workspace { - ws_idx: usize, - }, - GitWorkspace { - ws_idx: usize, - is_linked_worktree: bool, - has_worktree_children: bool, - collapsed: bool, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - source_pane_id: Option, - has_manual_label: bool, - right_click_passthrough: bool, - }, -} - -/// Right-click context menu state. -pub struct ContextMenuState { - pub kind: ContextMenuKind, - pub x: u16, - pub y: u16, - pub list: MenuListState, -} - -impl ContextMenuState { - pub fn items(&self) -> Vec<&'static str> { - match self.kind { - ContextMenuKind::Workspace { .. } => vec!["Rename", "Close"], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: false, - .. - } => vec!["Rename", "Close", "New worktree", "Open worktree..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: true, - .. - } => vec!["Rename", "Close", "Delete worktree checkout..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: true, - collapsed, - .. - } => vec![ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - if collapsed { "Expand" } else { "Collapse" }, - ], - ContextMenuKind::Tab { .. } => vec!["New tab", "Rename", "Close"], - ContextMenuKind::Pane { - source_pane_id, - has_manual_label, - right_click_passthrough, - .. - } => { - let mut items = vec!["Rename pane"]; - if has_manual_label { - items.push("Clear pane name"); - } - if source_pane_id.is_some() { - items.push("Swap with focused pane"); - } - items.extend(["Split right", "Split down", "Zoom"]); - items.push(if right_click_passthrough { - "Use Herdr right-click menu" - } else { - "Send right-clicks to pane" - }); - items.push("Close pane"); - items - } - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastKind { NeedsAttention, @@ -1389,6 +754,7 @@ pub struct CopyFeedback { pub message: String, } +#[derive(Debug)] pub struct ReleaseNotesState { pub version: String, pub body: String, @@ -1396,6 +762,7 @@ pub struct ReleaseNotesState { pub preview: bool, } +#[derive(Debug)] pub struct ProductAnnouncementState { pub version: String, pub id: String, @@ -1405,20 +772,6 @@ pub struct ProductAnnouncementState { pub preview: bool, } -#[derive(Default)] -pub struct KeybindHelpState { - pub scroll: u16, - pub query: String, - pub search_focused: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SidebarWidthSource { - ConfigDefault, - Persisted, - Manual, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PaneFocusTarget { pub workspace_id: String, @@ -1445,61 +798,16 @@ pub struct AppState { pub(crate) previous_pane_focus: Option, pub selected: usize, pub mode: Mode, - /// Stable workspace identity captured when the close confirmation opens. - pub(crate) confirm_close_workspace_id: Option, pub should_quit: bool, - /// In monolithic --no-session mode, detach exits the app because there is no server to detach from. - pub detach_exits: bool, - /// Set when the current client should detach from the persistent session. - /// The server's event loop checks this and handles client detach. - pub detach_requested: bool, - pub request_new_workspace: bool, - pub request_new_tab: bool, - pub request_new_linked_worktree: Option, - pub request_open_existing_worktree: Option, - pub request_new_workspace_cwd: Option, - pub request_remove_linked_worktree: Option, - pub request_submit_worktree_create: bool, - pub request_submit_worktree_open: bool, - pub request_submit_worktree_remove: bool, - pub request_reload_config: bool, /// Set when the headless server should ask attached clients to reload /// their client-local sound config from disk. pub request_client_config_reload: bool, - /// Set when UI interaction requested a clipboard write that must be - /// handled by the outer App/event loop instead of directly from AppState. - pub request_clipboard_write: Option>, - pub creating_new_tab: bool, - pub requested_new_tab_name: Option, - pub pending_workspace_create_cwd: Option, - pub rename_pane_target: Option, - pub worktree_create: Option, - pub worktree_open: Option, - pub worktree_remove: Option, pub worktree_directory: std::path::PathBuf, - pub collapsed_space_keys: std::collections::HashSet, - pub request_complete_onboarding: bool, - pub name_input: String, - pub name_input_replace_on_type: bool, - pub release_notes: Option, + /// Latest endpoint-owned release notes, cached outside render paths. + pub latest_release_notes: Option, pub product_announcement: Option, - pub keybind_help: KeybindHelpState, - pub navigator: NavigatorState, - pub copy_mode: Option, - pub workspace_scroll: usize, - pub agent_panel_scroll: usize, - pub tab_scroll: usize, - pub tab_scroll_follow_active: bool, - pub mobile_switcher_scroll: usize, - // View geometry (computed before render, consumed by render + mouse) + // Geometry of the most recently computed server pane surface. pub view: ViewState, - pub(crate) drag: Option, - pub(crate) workspace_presses: - std::collections::HashMap, - pub(crate) tab_presses: std::collections::HashMap, - pub selection: Option, - pub selection_autoscroll: Option, - pub context_menu: Option, // Notifications pub update_available: Option, pub update_install_command: String, @@ -1508,7 +816,6 @@ pub struct AppState { pub config_diagnostic: Option, pub toast: Option, pub pending_agent_notifications: std::collections::HashMap, - pub copy_feedback: Option, /// Last reported focus state for the outer terminal hosting herdr. /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option, @@ -1517,45 +824,20 @@ pub struct AppState { pub prefix_mods: KeyModifiers, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), - pub default_sidebar_width: u16, - pub sidebar_width: u16, - pub sidebar_min_width: u16, - pub sidebar_max_width: u16, - pub mobile_width_threshold: u16, - pub sidebar_width_source: SidebarWidthSource, - pub sidebar_width_auto: bool, - pub sidebar_collapsed: bool, - pub sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig, - /// Ratio of sidebar height allocated to the workspaces section. - pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, - pub status_indicators: crate::config::StatusIndicatorStyle, /// Transient session-wide projection override for the built-in Agents view. pub agent_view_override: Option, pub sidebar_agents: crate::config::AgentsSidebarConfig, pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, - /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only - /// captures mouse while the focused pane app requests mouse reporting. - pub mouse_capture: bool, - pub copy_on_select: bool, - pub right_click_passthrough_modifiers: Option, - pub right_click_passthrough: Option, - pub redraw_on_focus_gained: bool, - pub mouse_scroll_lines: usize, pub confirm_close: bool, - pub prompt_new_tab_name: bool, - pub prompt_new_workspace_name: bool, pub pane_borders: bool, pub pane_outer_borders: bool, pub pane_scrollbars: bool, pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, - pub hide_tab_bar_when_single_tab: bool, - pub tab_bar_position: TabBarPositionConfig, pub tab_bar_right: Vec, pub tab_bar_right_separator: String, - pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. pub reveal_hidden_cursor_for_cjk_ime: bool, @@ -1565,20 +847,12 @@ pub struct AppState { pub cjk_ime_agents: Vec, /// DECSCUSR shape parameter (1–6) for the IME anchor cursor. pub cjk_ime_cursor_shape: u8, - /// While prefix mode is active, switch the macOS host input source to an - /// ASCII-capable layout so prefix commands register as ASCII even when a - /// CJK IME is active. macOS only; a no-op elsewhere. See - /// `[experimental] switch_ascii_input_source_in_prefix`. - pub switch_ascii_input_source_in_prefix: bool, pub kitty_graphics_enabled: bool, pub default_shell: String, pub shell_mode: crate::config::ShellModeConfig, pub new_terminal_cwd: NewTerminalCwdConfig, pub pane_scrollback_limit_bytes: usize, - #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth - pub accent: Color, pub sound: SoundConfig, - pub local_sound_playback: bool, pub toast_config: ToastConfig, pub keybinds: Keybinds, /// UI color palette — all sidebar/UI colors centralized for theming. @@ -1591,16 +865,11 @@ pub struct AppState { pub host_terminal_appearance: Option, /// True when the foreground host explicitly reported appearance via Mode 2031. pub host_terminal_appearance_explicit: bool, - /// Settings panel state. - pub settings: SettingsState, - /// Cached integration recommendations for onboarding/settings UI. + /// Cached integration recommendations and detection manifest summaries. pub integration_recommendations: Vec, - /// Cached detection manifest source/version summaries for runtime/API status. pub agent_manifest_summaries: Vec, /// Cached remote detection manifest update diagnostics for runtime/API status. pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus, - /// Result messages from the latest integration install action. - pub integration_install_messages: Vec, /// Installed or linked plugins known to this running Herdr instance. pub(crate) installed_plugins: InstalledPluginRegistry, /// Pane ids opened through the plugin pane API. @@ -1611,14 +880,10 @@ pub struct AppState { pub(crate) plugin_command_logs: Vec, pub(crate) next_plugin_command_log_id: u64, pub(crate) plugin_commands_in_flight: usize, - /// Highlight state for the bottom-right global launcher menu. - pub global_menu: MenuListState, /// Resolved host terminal default colors for theming embedded panes. pub host_terminal_theme: TerminalTheme, /// Last known foreground host terminal cell size in pixels. pub(crate) host_cell_size: crate::kitty_graphics::HostCellSize, - /// Exact pixel provenance only while one confirmed SGR report is dispatched. - pub(crate) host_mouse_pixels: Option, /// Set when a persisted session snapshot would change. pub session_dirty: bool, /// Terminal runtimes that should be shut down by the app/runtime layer @@ -1635,18 +900,6 @@ impl AppState { self.pane_id_aliases.remove(&pane_id.raw()); } - pub fn sound_enabled(&self) -> bool { - self.sound.enabled - } - - pub fn toast_delivery(&self) -> ToastDelivery { - self.toast_config.delivery - } - - pub fn agent_border_labels_enabled(&self) -> bool { - self.show_agent_labels_on_pane_borders - } - pub(crate) fn pane_exposes_host_cursor( &self, _ws_idx: usize, @@ -1655,27 +908,17 @@ impl AppState { true } - pub(crate) fn integration_updates_available(&self) -> bool { - self.integration_recommendations - .iter() - .any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated) - } - pub(crate) fn refresh_agent_manifest_summaries(&mut self) { self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries(); } - pub(crate) fn global_menu_attention_badge_visible(&self) -> bool { - self.update_available.is_some() || self.integration_updates_available() - } - - pub(crate) fn global_menu_item_has_badge(&self, item: &str) -> bool { - (item == "update ready" && self.update_available.is_some()) - || (item == "settings" && self.integration_updates_available()) - } - - pub(crate) fn settings_section_has_badge(&self, section: SettingsSection) -> bool { - section == SettingsSection::Integrations && self.integration_updates_available() + pub(crate) fn integration_updates_available(&self) -> bool { + self.integration_recommendations + .iter() + .any(|recommendation| { + recommendation.state == crate::integration::IntegrationStatusKind::Outdated + && recommendation.needs_install() + }) } pub(crate) fn app_surface_pane_ids(&self) -> std::collections::HashSet { @@ -1702,24 +945,9 @@ impl AppState { &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> bool { - self.mode == Mode::Terminal - && self - .active - .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) - .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) - } - - pub(crate) fn should_capture_host_mouse_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - self.mouse_capture - || self.popup_pane.is_some() - || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) - } - - pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool { - crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) + self.active + .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -1756,26 +984,6 @@ impl AppState { terminal_runtimes.get(terminal_id) } - #[cfg(test)] - pub(crate) fn runtime_for_pane<'a>( - &'a self, - terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option<&'a crate::terminal::TerminalRuntime> { - self.workspaces.iter().find_map(|ws| { - #[cfg(test)] - if let Some(runtime) = ws.test_runtimes.get(&pane_id) { - return Some(runtime); - } - #[cfg(test)] - if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) { - return Some(runtime); - } - let terminal_id = ws.terminal_id(pane_id)?; - terminal_runtimes.get(terminal_id) - }) - } - pub(crate) fn focused_runtime_in_workspace<'a>( &'a self, terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, @@ -1786,6 +994,28 @@ impl AppState { self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) } + pub(crate) fn pane_visible_on_active_surface( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> bool { + if self.active != Some(ws_idx) { + return false; + } + let Some(tab) = self + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.active_tab()) + else { + return false; + }; + if tab.zoomed { + tab.layout.focused() == pane_id + } else { + tab.layout.pane_ids().contains(&pane_id) + } + } + pub fn is_active_pane( &self, ws_idx: usize, @@ -1838,66 +1068,15 @@ impl AppState { previous_pane_focus: None, selected: 0, mode: Mode::Navigate, - confirm_close_workspace_id: None, should_quit: false, - detach_exits: false, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"), - collapsed_space_keys: std::collections::HashSet::new(), - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, + latest_release_notes: None, product_announcement: None, - keybind_help: KeybindHelpState::default(), - navigator: NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: ViewState { - layout: ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: std::collections::HashMap::new(), - tab_presses: std::collections::HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available: None, update_install_command: "herdr update".into(), latest_release_notes_available: false, @@ -1905,7 +1084,6 @@ impl AppState { config_diagnostic: None, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, @@ -1913,57 +1091,32 @@ impl AppState { crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, ), - default_sidebar_width: 26, - sidebar_width: 26, - sidebar_min_width: 18, - sidebar_max_width: 36, - mobile_width_threshold: crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD, - sidebar_width_source: SidebarWidthSource::ConfigDefault, - sidebar_width_auto: false, - sidebar_collapsed: false, - sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, - sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, - status_indicators: crate::config::StatusIndicatorStyle::Dots, agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, - mouse_capture: true, - copy_on_select: true, - right_click_passthrough_modifiers: None, - right_click_passthrough: None, - redraw_on_focus_gained: true, - mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, - prompt_new_tab_name: true, - prompt_new_workspace_name: false, pane_borders: true, pane_outer_borders: true, pane_scrollbars: true, pane_gaps: false, show_agent_labels_on_pane_borders: false, - hide_tab_bar_when_single_tab: false, - tab_bar_position: TabBarPositionConfig::Top, tab_bar_right: Vec::new(), tab_bar_right_separator: " ".into(), - pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, cjk_ime_agents: Vec::new(), cjk_ime_cursor_shape: 2, // steady_block - switch_ascii_input_source_in_prefix: false, kitty_graphics_enabled: false, default_shell: String::new(), shell_mode: crate::config::ShellModeConfig::Auto, new_terminal_cwd: NewTerminalCwdConfig::Follow, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, - accent: Color::Cyan, sound: SoundConfig { enabled: false, ..SoundConfig::default() }, - local_sound_playback: false, toast_config: ToastConfig::default(), keybinds: Keybinds::default(), palette: Palette::catppuccin(), @@ -1978,27 +1131,18 @@ impl AppState { }, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: SettingsState { - section: SettingsSection::Theme, - list: SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: Vec::new(), agent_manifest_summaries: Vec::new(), agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus::default(), - integration_install_messages: Vec::new(), installed_plugins: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), } @@ -2063,52 +1207,12 @@ impl AppState { self.pending_agent_notifications.is_empty(), "empty app state must not keep pending agent notifications" ); - assert!( - self.copy_mode.is_none(), - "empty app state must not keep copy mode" - ); - assert!( - self.rename_pane_target.is_none(), - "empty app state must not keep rename pane target" - ); - assert!( - self.selection.is_none(), - "empty app state must not keep text selection" - ); - assert!( - self.selection_autoscroll.is_none(), - "empty app state must not keep selection autoscroll" - ); if let Some(toast) = &self.toast { assert!( toast.target.is_none(), "empty app state must not keep pane-targeted toast" ); } - assert!( - self.right_click_passthrough.is_none(), - "empty app state must not keep right-click passthrough gesture" - ); - assert!( - self.drag.is_none(), - "empty app state must not keep drag state" - ); - assert!( - self.workspace_presses.is_empty(), - "empty app state must not keep workspace press state" - ); - assert!( - self.tab_presses.is_empty(), - "empty app state must not keep tab press state" - ); - assert!( - self.context_menu.is_none(), - "empty app state must not keep context menu" - ); - assert!( - self.host_mouse_pixels.is_none(), - "empty app state must not keep host mouse pixel provenance" - ); return; } @@ -2183,25 +1287,6 @@ impl AppState { workspace_id ); }; - let assert_workspace_index = |ws_idx: usize, context: &str| { - assert!( - ws_idx < self.workspaces.len(), - "{context} references workspace index {} out of bounds for {} workspaces", - ws_idx, - self.workspaces.len() - ); - }; - let assert_tab_index = |ws_idx: usize, tab_idx: usize, context: &str| { - assert_workspace_index(ws_idx, context); - assert!( - tab_idx < self.workspaces[ws_idx].tabs.len(), - "{context} references tab index {} out of bounds for workspace {} with {} tabs", - tab_idx, - ws_idx, - self.workspaces[ws_idx].tabs.len() - ); - }; - for (&raw, &pane_id) in &self.pane_id_aliases { assert_live_pane(pane_id, &format!("raw pane alias {raw}")); } @@ -2243,96 +1328,6 @@ impl AppState { for &pane_id in self.plugin_panes.keys() { assert_live_pane(pane_id, "plugin pane record"); } - if let Some(copy_mode) = &self.copy_mode { - assert_live_pane(copy_mode.pane_id, "copy mode"); - } - if let Some(pane_id) = self.rename_pane_target { - assert_live_pane(pane_id, "rename pane target"); - } - if let Some(selection) = &self.selection { - assert_live_pane(selection.pane_id, "text selection"); - } else { - assert!( - self.selection_autoscroll.is_none(), - "selection autoscroll must not remain without an active text selection" - ); - } - if let Some(gesture) = &self.right_click_passthrough { - assert_live_pane(gesture.pane_info.id, "right-click passthrough gesture"); - } - if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target, - .. - } => { - assert_workspace_index(*source_ws_idx, "workspace drag source"); - if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target { - assert_workspace_index(*ws_idx, "workspace drag target"); - } - } - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx, - .. - } => { - assert_tab_index(*ws_idx, *source_tab_idx, "tab drag source"); - if let Some(insert_idx) = insert_idx { - assert!( - *insert_idx <= self.workspaces[*ws_idx].tabs.len(), - "tab drag insert index {} out of bounds for workspace {} with {} tabs", - insert_idx, - ws_idx, - self.workspaces[*ws_idx].tabs.len() - ); - } - } - DragTarget::PaneScrollbar { pane_id, .. } => { - assert_live_pane(*pane_id, "pane scrollbar drag") - } - _ => {} - } - } - for press in self.workspace_presses.values() { - assert_workspace_index(press.ws_idx, "workspace press"); - } - for press in self.tab_presses.values() { - assert_tab_index(press.ws_idx, press.tab_idx, "tab press"); - } - if let Some(menu) = &self.context_menu { - match menu.kind { - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. } => { - assert_workspace_index(ws_idx, "context menu workspace") - } - ContextMenuKind::Tab { ws_idx, tab_idx } => { - assert_tab_index(ws_idx, tab_idx, "context menu tab") - } - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - } => { - assert_tab_index(ws_idx, tab_idx, "context menu pane tab"); - assert!( - self.workspaces[ws_idx].tabs[tab_idx] - .panes - .contains_key(&pane_id), - "context menu pane references pane {:?} outside workspace {} tab {}", - pane_id, - ws_idx, - tab_idx - ); - if let Some(source_pane_id) = source_pane_id { - assert_live_pane(source_pane_id, "context menu source pane"); - } - } - } - } } pub fn insert_test_runtime( @@ -2400,81 +1395,6 @@ mod tests { state.assert_invariants_for_test(); } - fn navigator_row_for_display(is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth: if is_workspace { 0 } else { 1 }, - label: String::new(), - meta: String::new(), - status: crate::detect::AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - #[test] - fn navigator_display_lines_separate_workspace_groups() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![ - NavigatorDisplayLine::Row(0), - NavigatorDisplayLine::Row(1), - NavigatorDisplayLine::Spacer, - NavigatorDisplayLine::Row(2), - NavigatorDisplayLine::Row(3), - ] - ); - } - - #[test] - fn navigator_display_lines_have_no_leading_spacer() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![NavigatorDisplayLine::Row(0), NavigatorDisplayLine::Row(1)] - ); - assert!(navigator_display_lines(&[]).is_empty()); - } - - #[test] - fn navigator_display_index_maps_row_to_line() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - assert_eq!(navigator_display_index_of_row(&lines, 2), Some(3)); - assert_eq!(navigator_display_index_of_row(&lines, 9), None); - } - - #[test] - fn navigator_first_row_skips_spacer_lines() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - // Line 2 is the spacer before the second workspace. - assert_eq!(navigator_first_row_at_or_after(&lines, 2), Some(2)); - assert_eq!(navigator_first_row_at_or_after(&lines, 4), None); - } - fn rgb_luminance(color: Color) -> f64 { let Color::Rgb(r, g, b) = color else { panic!("expected RGB color, got {color:?}"); @@ -2501,7 +1421,7 @@ mod tests { #[test] fn built_in_theme_names_resolve() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { assert!( Palette::from_name(name).is_some(), "theme should resolve: {name}" @@ -2511,7 +1431,7 @@ mod tests { #[test] fn built_in_active_rows_remain_visible_with_matching_terminal_backgrounds() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2533,7 +1453,7 @@ mod tests { #[test] fn built_in_selection_rows_stay_distinct_from_background_and_active_rows() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2559,7 +1479,7 @@ mod tests { #[test] fn built_in_themes_leave_sidebar_background_unset() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { let palette = Palette::from_name(name).unwrap(); assert_eq!( palette.sidebar_bg, @@ -2620,70 +1540,4 @@ mod tests { KeyModifiers::SHIFT, )); } - - #[test] - fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: true, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "Delete worktree checkout..."] - ); - } - - #[test] - fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "New worktree", "Open worktree..."] - ); - } - - #[test] - fn parent_worktree_context_menu_uses_repo_actions() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &[ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - "Collapse" - ] - ); - } } diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs index eca5a314..596c2117 100644 --- a/src/app/tab_bar_status.rs +++ b/src/app/tab_bar_status.rs @@ -524,7 +524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 0ed2a0f7..94932f4a 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -88,7 +88,13 @@ mod tests { async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -161,7 +167,13 @@ mod tests { async fn syncing_pending_titles_preserves_sidebar_render_impact() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -189,7 +201,13 @@ mod tests { fn sidebar_redraws_only_for_the_configured_title_form() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; app.state.sidebar_agents.rows_by_agent.insert( "claude".into(), diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index 58f95bec..ed0f632d 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,70 +1,6 @@ use super::App; impl App { - #[cfg(not(windows))] - pub(super) fn query_host_terminal_appearance(&self) { - use std::io::Write; - - let _ = std::io::stdout() - .write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()); - let _ = std::io::stdout().flush(); - } - - pub(super) fn query_host_terminal_theme(&self) { - use std::io::Write; - - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - let _ = std::io::stdout().write_all(query.as_bytes()); - let _ = std::io::stdout().flush(); - } - - pub(super) fn update_host_terminal_theme( - &mut self, - kind: crate::terminal_theme::DefaultColorKind, - color: crate::terminal_theme::RgbColor, - ) -> bool { - let mut changed = false; - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.state.host_terminal_appearance_explicit - { - changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false); - } - let next_theme = self.state.host_terminal_theme.with_color(kind, color); - changed | self.set_host_terminal_theme(next_theme) - } - - pub(super) fn update_host_terminal_palette_colors( - &mut self, - colors: &[(u8, crate::terminal_theme::RgbColor)], - ) -> bool { - let mut next_theme = self.state.host_terminal_theme; - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); - } - self.set_host_terminal_theme(next_theme) - } - - pub(super) fn set_host_terminal_appearance( - &mut self, - appearance: crate::terminal_theme::HostAppearance, - explicit: bool, - ) -> bool { - if self.state.host_terminal_appearance == Some(appearance) - && self.state.host_terminal_appearance_explicit == explicit - { - return false; - } - if self.state.host_terminal_appearance_explicit && !explicit { - return false; - } - self.state.host_terminal_appearance = Some(appearance); - self.state.host_terminal_appearance_explicit = explicit; - self.apply_host_terminal_appearance_to_panes(); - self.refresh_effective_app_theme() - } - pub(crate) fn set_host_terminal_appearance_state( &mut self, appearance: Option, diff --git a/src/app/window_title.rs b/src/app/window_title.rs index 841c6a92..09d2f850 100644 --- a/src/app/window_title.rs +++ b/src/app/window_title.rs @@ -107,7 +107,13 @@ mod tests { fn test_app() -> App { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("herd")]; app.state.active = Some(0); app.state.ensure_test_terminals(); diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 9993a70e..b4ac1482 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1,972 +1,6 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use super::{ - state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState}, - App, Mode, -}; -#[cfg(test)] -use crate::events::AppEvent; -use crate::events::{WorktreeAddResult, WorktreeRemoveResult}; +use super::App; impl App { - fn worktree_source_metadata( - &self, - ws_idx: usize, - ) -> Result< - ( - Option, - crate::workspace::GitSpaceMetadata, - std::path::PathBuf, - String, - ), - String, - > { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err("Workspace not found.".into()); - }; - let existing_membership = ws.worktree_space().cloned(); - if existing_membership - .as_ref() - .is_some_and(|membership| membership.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let space = existing_membership - .as_ref() - .map_or(git_space, |membership| { - Some(crate::workspace::GitSpaceMetadata { - key: membership.key.clone(), - checkout_key: membership.checkout_path.display().to_string(), - repo_name: membership.label.clone(), - repo_root: membership.repo_root.clone(), - is_linked_worktree: membership.is_linked_worktree, - }) - }) - .ok_or_else(|| { - "Herdr worktree actions require a workspace inside a Git work tree.".to_string() - })?; - let source_checkout_path = existing_membership - .as_ref() - .map(|membership| membership.checkout_path.clone()) - .unwrap_or_else(|| space.repo_root.clone()); - let source_workspace_id = self.state.workspaces[ws_idx].id.clone(); - Ok(( - existing_membership, - space, - source_checkout_path, - source_workspace_id, - )) - } - - pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let repo_name = space.repo_name.clone(); - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0); - let branch = crate::worktree::generated_branch_slug(seed); - let checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &repo_name, - &branch, - ); - - tracing::info!( - ws_idx, - repo_root = %space.repo_root.display(), - branch, - checkout_path = %checkout_path.display(), - "opening worktree dialog" - ); - self.state.selected = ws_idx; - self.state.name_input = branch.clone(); - self.state.name_input_replace_on_type = true; - self.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path, - source_existing_membership: existing_membership, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name, - branch, - checkout_path, - error: None, - creating: false, - }); - self.state.mode = Mode::NewLinkedWorktree; - } - - pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return; - }; - if !ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - self.state.config_diagnostic = - Some("This workspace is not a Herdr-managed worktree checkout.".into()); - return; - } - let Some(space) = ws.worktree_space().cloned() else { - return; - }; - self.state.selected = ws_idx; - self.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: ws.id.clone(), - repo_root: space.repo_root, - path: space.checkout_path, - error: None, - removing: false, - force_confirmation: false, - }); - self.state.mode = Mode::ConfirmRemoveWorktree; - } - - pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let list = match crate::worktree::list_existing_worktrees(&space.repo_root, false) { - Ok(list) => list, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - let entries = list - .into_iter() - .filter(|entry| !entry.is_bare && !entry.is_prunable) - .map(|entry| { - let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path); - let entry_checkout_key = entry_checkout_path.display().to_string(); - let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root); - let already_open_ws_idx = self.state.workspaces.iter().position(|ws| { - if let Some(membership) = ws.worktree_space() { - return crate::worktree::canonical_or_original(&membership.checkout_path) - == entry_checkout_path; - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from( - &self.state.terminals, - &self.terminal_runtimes, - ) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key) - { - return true; - } - - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .is_some_and(|cwd| { - crate::worktree::canonical_or_original(cwd) == entry_checkout_path - }) - }); - WorktreeOpenEntry { - is_linked_worktree: entry_checkout_path != repo_checkout_path, - path: entry.path, - branch: entry.branch, - already_open_ws_idx, - } - }) - .collect::>(); - - if entries.is_empty() { - self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into()); - return; - } - - self.state.selected = ws_idx; - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: existing_membership, - source_checkout_path, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name: space.repo_name, - entries, - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - self.state.mode = Mode::OpenExistingWorktree; - } - - pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) - { - return; - } - self.close_worktree_create_dialog(); - } - KeyCode::Enter => self.submit_worktree_create_via_api(), - KeyCode::Backspace => { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } else { - self.state.name_input.pop(); - } - self.sync_worktree_branch_from_input(); - } - KeyCode::Char(c) => { - self.insert_worktree_create_text(&c.to_string()); - } - _ => {} - } - } - - pub(crate) fn insert_worktree_create_text(&mut self, text: &str) { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - self.state.name_input.push_str(text); - self.sync_worktree_branch_from_input(); - } - - pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.worktree_open = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Up => { - if let Some(open) = &mut self.state.worktree_open { - open.select_previous_filtered(); - } - } - KeyCode::Down => { - if let Some(open) = &mut self.state.worktree_open { - open.select_next_filtered(); - } - } - KeyCode::Char('/') => { - if let Some(open) = &mut self.state.worktree_open { - if open.search_focused { - open.query.push('/'); - open.normalize_selection(); - } else { - open.search_focused = true; - } - } - } - KeyCode::Char(ch) - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) - && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) - && !ch.is_control() => - { - self.insert_worktree_open_search_text(&ch.to_string()); - } - KeyCode::Backspace - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) => - { - if let Some(open) = &mut self.state.worktree_open { - open.query.pop(); - open.normalize_selection(); - } - } - KeyCode::Enter => self.submit_worktree_open_via_api(), - _ => {} - } - } - - pub(crate) fn insert_worktree_open_search_text(&mut self, text: &str) { - let Some(open) = &mut self.state.worktree_open else { - return; - }; - if !open.search_focused { - return; - } - open.query.push_str(text); - open.normalize_selection(); - } - - #[cfg(test)] - pub(crate) fn open_selected_existing_worktree(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - let source_existing_membership = open.source_existing_membership.clone(); - let source_checkout_path = open.source_checkout_path.clone(); - let source_repo_root = open.source_repo_root.clone(); - let repo_key = open.repo_key.clone(); - let repo_name = open.repo_name.clone(); - self.state.worktree_open = None; - - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&entry.path) { - self.mark_opened_existing_worktree_membership( - &source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - ws_idx, - entry.path, - entry.is_linked_worktree, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - self.emit_worktree_opened_for_workspace(ws_idx, true); - return; - } - - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = source_existing_membership.clone().unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path.clone(), - is_linked_worktree: false, - }, - ); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - - match self.create_workspace_with_options(entry.path.clone(), true) { - Ok(new_ws_idx) => { - self.set_worktree_membership( - new_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: entry.path, - is_linked_worktree: entry.is_linked_worktree, - }, - false, - ); - self.emit_workspace_open_events(new_ws_idx); - self.emit_worktree_opened_for_workspace(new_ws_idx, false); - } - Err(err) => { - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - entries: vec![entry], - selected: 0, - query: String::new(), - search_focused: false, - error: Some(format!("failed to open worktree: {err}")), - }); - self.state.mode = Mode::OpenExistingWorktree; - } - } - } - - // The caller has already extracted the open-worktree dialog state; keeping the - // membership fields explicit here avoids borrowing AppState across workspace creation. - #[allow(clippy::too_many_arguments)] - #[cfg(test)] - fn mark_opened_existing_worktree_membership( - &mut self, - source_workspace_id: &str, - source_existing_membership: Option, - source_checkout_path: std::path::PathBuf, - source_repo_root: std::path::PathBuf, - repo_key: String, - repo_name: String, - target_ws_idx: usize, - target_path: std::path::PathBuf, - target_is_linked_worktree: bool, - ) { - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = - source_existing_membership.unwrap_or(crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - self.set_worktree_membership( - target_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: target_path, - is_linked_worktree: target_is_linked_worktree, - }, - true, - ); - } - - fn close_worktree_create_dialog(&mut self) { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn sync_worktree_branch_from_input(&mut self) { - let Some(create) = &mut self.state.worktree_create else { - return; - }; - create.branch = self.state.name_input.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &create.branch, - ); - create.error = None; - } - - #[cfg(test)] - pub(crate) fn start_worktree_add(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - - let parent_dir = create - .checkout_path - .parent() - .map(std::path::Path::to_path_buf); - tracing::info!( - repo_root = %create.source_repo_root.display(), - branch = %create.branch, - checkout_path = %create.checkout_path.display(), - "starting git worktree add" - ); - let path = create.checkout_path.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let branch = create.branch.clone(); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) - } else { - Ok(()) - } - .and_then(|()| { - crate::worktree::run_worktree_add_command( - &source_checkout_path, - &path, - &branch, - "HEAD", - false, - ) - }); - let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( - WorktreeAddResult { - path, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_create_via_api(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - let workspace_id = create.source_workspace_id.clone(); - let checkout_path = create.checkout_path.display().to_string(); - - let immediate_response = self.runtime_worktree_create_deferred( - "tui.worktree.create", - crate::api::schema::WorktreeCreateParams { - workspace_id: Some(workspace_id), - cwd: None, - branch: Some(branch), - path: Some(checkout_path), - base: Some("HEAD".into()), - focus: true, - label: None, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(create) = &mut self.state.worktree_create { - create.creating = false; - create.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) - { - return; - } - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Enter => self.submit_worktree_remove_via_api(), - _ => {} - } - } - - #[cfg(test)] - pub(crate) fn start_worktree_remove(&mut self) { - let Some((workspace_id, repo_root, path, force)) = - self.state.worktree_remove.as_mut().and_then(|remove| { - if remove.removing { - return None; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false) - .unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return None; - } - remove.removing = true; - remove.error = None; - Some(( - remove.workspace_id.clone(), - remove.repo_root.clone(), - remove.path.clone(), - remove.force_confirmation, - )) - }) - else { - return; - }; - - if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(force) { - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - { - self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); - } - } - - let (workspace_snapshot, worktree_snapshot) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - .map(|ws_idx| { - let workspace = Box::new(self.workspace_info(ws_idx)); - let worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| Box::new(self.worktree_info_for_membership(&space, None))); - (Some(workspace), worktree) - }) - .unwrap_or((None, None)); - - let command = - crate::worktree::build_worktree_remove_command(&repo_root, &path, force, false); - tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove"); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = crate::worktree::run_worktree_remove_command_with_recovery( - &command, &repo_root, &path, force, false, - ); - let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( - WorktreeRemoveResult { - workspace_id, - path, - workspace: workspace_snapshot, - worktree: worktree_snapshot, - forced: force, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_open_via_api(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - - let response = self.runtime_worktree_open( - "tui.worktree.open", - crate::api::schema::WorktreeOpenParams { - workspace_id: Some(source_workspace_id), - cwd: None, - path: Some(entry.path.display().to_string()), - branch: None, - focus: true, - label: None, - trust_repository: false, - }, - ); - if serde_json::from_str::(&response).is_ok() { - self.state.worktree_open = None; - self.state.mode = Mode::Terminal; - } else if let Ok(error) = - serde_json::from_str::(&response) - { - if let Some(open) = &mut self.state.worktree_open { - open.error = Some(error.error.message); - } - } - } - - pub(crate) fn submit_worktree_remove_via_api(&mut self) { - let Some(remove) = self.state.worktree_remove.as_mut() else { - return; - }; - if remove.removing { - return; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false).unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return; - } - - remove.removing = true; - remove.error = None; - let workspace_id = remove.workspace_id.clone(); - let force = remove.force_confirmation; - let immediate_response = self.runtime_worktree_remove_deferred( - "tui.worktree.remove", - crate::api::schema::WorktreeRemoveParams { - workspace_id, - force, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(remove) = &mut self.state.worktree_remove { - remove.removing = false; - remove.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) { - if result.api_request.is_some() { - self.handle_api_worktree_add_finished(result); - return; - } - let Some(create) = &mut self.state.worktree_create else { - return; - }; - if create.checkout_path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed"); - let path = create.checkout_path.clone(); - let source_workspace_id = create.source_workspace_id.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let source_existing_membership = create.source_existing_membership.clone(); - let repo_key = create.repo_key.clone(); - let repo_name = create.repo_name.clone(); - let source_repo_root = create.source_repo_root.clone(); - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - let source_membership = source_existing_membership.unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }, - ); - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&path) { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - true, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } else { - match self.create_workspace_with_options(path.clone(), true) { - Ok(ws_idx) => { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - false, - ); - self.emit_workspace_open_events(ws_idx); - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } - Err(err) => { - self.state.config_diagnostic = Some(format!( - "created worktree but failed to open workspace: {err}" - )); - self.state.mode = Mode::Navigate; - } - } - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed"); - create.creating = false; - create.error = Some(message); - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) { - if result.api_request.is_some() { - self.handle_api_worktree_remove_finished(result); - return; - } - let Some(remove) = &mut self.state.worktree_remove else { - return; - }; - if remove.workspace_id != result.workspace_id || remove.path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed"); - let forced = result.forced; - self.state.worktree_remove = None; - let mut workspace_id = result.workspace_id.clone(); - let mut workspace_snapshot = result.workspace.as_deref().cloned(); - let mut worktree = result.worktree.as_deref().cloned(); - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == result.workspace_id) - { - workspace_id = self.public_workspace_id(ws_idx); - workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx)); - if worktree.is_none() { - worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| self.worktree_info_for_membership(&space, None)); - } - let still_same_linked_worktree = self.state.workspaces[ws_idx] - .worktree_space() - .is_some_and(|space| { - space.is_linked_worktree && space.checkout_path == result.path - }); - if still_same_linked_worktree { - self.close_removed_linked_worktree_workspace(ws_idx); - self.shutdown_detached_terminal_runtimes(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { - workspace_id: workspace_id.clone(), - workspace: workspace_snapshot.clone(), - }, - }); - } - } else if let Some(snapshot) = workspace_snapshot.as_ref() { - workspace_id = snapshot.workspace_id.clone(); - } - if let Some(worktree) = worktree { - self.emit_worktree_removed_event( - workspace_id, - workspace_snapshot, - worktree, - forced, - ); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed"); - remove.removing = false; - if !remove.force_confirmation - && crate::worktree::is_dirty_worktree_remove_error(&message) - { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message); - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn should_shutdown_workspace_terminal_runtimes_for_worktree_remove( force: bool, ) -> bool { @@ -1017,1384 +51,9 @@ impl App { } } -fn immediate_api_error_message(response: Option<&str>) -> Option { - response - .and_then(|response| { - serde_json::from_str::(response).ok() - }) - .map(|response| response.error.message) -} - #[cfg(test)] mod tests { - use super::*; - - fn unique_temp_path(name: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) - } - - fn run_git(repo: &std::path::Path, args: &[&str]) { - let status = std::process::Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .status() - .unwrap(); - assert!( - status.success(), - "git command failed: git -C {} {}", - repo.display(), - args.join(" ") - ); - } - - fn create_committed_repo(name: &str) -> std::path::PathBuf { - let repo = unique_temp_path(name); - std::fs::create_dir_all(&repo).unwrap(); - run_git(&repo, &["init", "--quiet"]); - run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); - run_git(&repo, &["config", "user.name", "Herdr Test"]); - std::fs::write(repo.join("README.md"), "test\n").unwrap(); - run_git(&repo, &["add", "README.md"]); - run_git(&repo, &["commit", "--quiet", "-m", "initial"]); - repo - } - - fn wait_for_worktree_event(app: &mut App) -> AppEvent { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(event) = app.event_rx.try_recv() { - return event; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - panic!("timed out waiting for worktree event"); - } - - fn app_for_worktree_tests() -> App { - app_for_worktree_tests_with_event_hub(crate::api::EventHub::default()) - } - - fn app_for_worktree_tests_with_event_hub(event_hub: crate::api::EventHub) -> App { - App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - event_hub, - ) - } - - fn event_kinds(event_hub: &crate::api::EventHub) -> Vec { - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect() - } - - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - #[tokio::test] - async fn ui_create_workspace_emits_initial_workspace_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - - app.create_workspace(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[tokio::test] - async fn ui_create_tab_emits_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - - app.create_tab(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[test] - fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() { - let mut app = app_for_worktree_tests(); - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "generated-branch".into(), - checkout_path: "/repo/herdr-generated-branch".into(), - error: None, - creating: false, - }); - - app.insert_worktree_create_text("feature/linear-302"); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert!(!app.state.name_input_replace_on_type); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[test] - fn worktree_open_search_accepts_pasted_text_when_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - WorktreeOpenEntry { - path: "/repo/herdr-main".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/feature-linear-302".into(), - branch: Some("feature/linear-302".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: true, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "linear-302"); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn worktree_open_search_ignores_paste_when_search_is_not_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: Vec::new(), - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - assert_eq!( - app.state - .worktree_open - .as_ref() - .map(|open| open.query.as_str()), - Some("") - ); - } - - #[test] - fn open_selected_existing_worktree_focuses_already_open_workspace() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("issue"), - ]; - app.state.workspaces[1].identity_cwd = "/repo/herdr-issue".into(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: app.state.workspaces[0].id.clone(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.worktree_open.is_none()); - assert!(app.state.workspaces[0].worktree_space().is_some()); - let target_membership = app.state.workspaces[1].worktree_space().unwrap(); - assert_eq!(target_membership.key, "repo-key"); - assert_eq!( - target_membership.checkout_path, - std::path::PathBuf::from("/repo/herdr-issue") - ); - assert!(target_membership.is_linked_worktree); - } - - #[tokio::test] - async fn ui_worktree_open_new_workspace_emits_api_parity_events() { - let checkout = unique_temp_path("app-worktree-open-event-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-event".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[tokio::test] - async fn open_selected_existing_worktree_recomputes_stale_already_open_state() { - let checkout = unique_temp_path("app-worktree-stale-open-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("other"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/stale-open".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.workspaces.len(), 3); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceUpdated, - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - let opened = event_hub - .events_after(0) - .into_iter() - .find_map(|(_, event)| match event.data { - crate::api::schema::EventData::WorktreeOpened { already_open, .. } => { - Some(already_open) - } - _ => None, - }) - .expect("worktree.opened should be emitted"); - assert!(!opened); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn worktree_open_search_filters_entries() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - WorktreeOpenEntry { - path: "/repo/fd-cleanup".into(), - branch: Some("fd-cleanup".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/bell-forward-macos-bounce".into(), - branch: Some("bell-forward-macos-bounce".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('/'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('f'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('d'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('-'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('c'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('l'), - crossterm::event::KeyModifiers::empty(), - )); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "fd-cl"); - assert_eq!(open.filtered_indices(), vec![1]); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() { - let repo = create_committed_repo("app-worktree-open-existing-repo"); - let checkout = unique_temp_path("app-worktree-open-existing-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-existing", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - let subdir = checkout.join("nested"); - std::fs::create_dir_all(&subdir).unwrap(); - - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("nested"), - ]; - app.state.workspaces[0].identity_cwd = repo; - app.state.workspaces[1].identity_cwd = subdir; - - app.open_existing_worktree_dialog(0); - - let open = app.state.worktree_open.as_ref().unwrap(); - let checkout = crate::worktree::canonical_or_original(&checkout); - let entry = open - .entries - .iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout) - .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries)); - assert_eq!(entry.already_open_ws_idx, Some(1)); - } - - #[test] - fn worktree_create_and_open_dialogs_reject_linked_child_source() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - app.state.mode = Mode::Navigate; - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.worktree_create.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - - app.state.config_diagnostic = None; - app.open_existing_worktree_dialog(0); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - } - - #[test] - fn sync_worktree_branch_updates_derived_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.name_input = "issue/137".into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: std::path::PathBuf::from("/repo/herdr"), - source_existing_membership: None, - source_repo_root: std::path::PathBuf::from("/repo/herdr"), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "old".into(), - checkout_path: std::path::PathBuf::from("/old"), - error: Some("old error".into()), - creating: false, - }); - - app.sync_worktree_branch_from_input(); - - let create = app.state.worktree_create.unwrap(); - assert_eq!(create.branch, "issue/137"); - assert_eq!( - create.checkout_path, - std::path::PathBuf::from("/w/herdr/issue-137") - ); - assert_eq!(create.error, None); - } - - #[test] - fn worktree_create_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - let branch = "issue/195"; - let checkout_path = - crate::worktree::default_checkout_path(&app.state.worktree_directory, "herdr", branch); - let checkout_key = crate::worktree::canonical_or_original(&checkout_path); - app.pending_api_worktree_creates.insert(checkout_key, 1); - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: Some(source_membership), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path, - error: None, - creating: false, - }); - - app.handle_worktree_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let create = app.state.worktree_create.as_ref().unwrap(); - assert!(!create.creating); - assert_eq!( - create.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn worktree_open_enter_submits_through_api_path() { - let repo = create_committed_repo("app-worktree-open-enter-repo"); - let checkout = unique_temp_path("app-worktree-open-enter-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-enter", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: repo.clone(), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-enter".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - run_git( - &repo, - &["worktree", "remove", "--force", checkout.to_str().unwrap()], - ); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn worktree_remove_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - app.open_remove_linked_worktree_confirmation(0); - app.pending_api_worktree_removes - .insert(workspace_id.clone(), 1); - - app.handle_worktree_remove_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert_eq!(remove.workspace_id, workspace_id); - assert!(!remove.removing); - assert_eq!( - remove.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { - let repo = create_committed_repo("app-worktree-create-event-repo"); - let worktree_root = unique_temp_path("app-worktree-create-event-root"); - let branch = "worktree/ui-create-event"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - branch, - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: repo.clone(), - source_existing_membership: Some(source_membership), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - let plugin_root = unique_temp_path("app-worktree-create-plugin"); - std::fs::create_dir_all(&plugin_root).unwrap(); - let manifest_path = plugin_root.join("herdr-plugin.toml"); - std::fs::write(&manifest_path, "id = 'example.ui-worktree-create'\n").unwrap(); - app.state.installed_plugins.insert( - "example.ui-worktree-create".into(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.ui-worktree-create".into(), - name: "UI Worktree Create".into(), - version: "0.1.0".into(), - min_herdr_version: "0.7.0".into(), - description: None, - manifest_path: manifest_path.display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: Vec::new(), - events: vec![crate::api::schema::PluginManifestEventHook { - on: "worktree.created".into(), - platforms: None, - command: vec!["sh".into(), "-c".into(), "true".into()], - }], - panes: Vec::new(), - link_handlers: Vec::new(), - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - ); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeCreated, - ] - ); - let events = event_hub.events_after(0); - let workspace_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorkspaceCreated) - .map(|(_, event)| event) - .expect("workspace.created should be emitted"); - let crate::api::schema::EventData::WorkspaceCreated { workspace } = &workspace_created.data - else { - panic!("unexpected event data"); - }; - let checkout_path = checkout.display().to_string(); - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - let worktree_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorktreeCreated) - .map(|(_, event)| event) - .expect("worktree.created should be emitted"); - let crate::api::schema::EventData::WorktreeCreated { - workspace, - worktree, - } = &worktree_created.data - else { - panic!("unexpected event data"); - }; - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - assert_eq!( - worktree.open_workspace_id.as_deref(), - Some(workspace.workspace_id.as_str()) - ); - assert!(app.state.plugin_command_logs.iter().any(|log| { - log.event.as_deref() == Some("worktree.created") - && log.status == crate::api::schema::PluginCommandStatus::Running - })); - - shutdown_test_runtimes(&mut app); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - let _ = std::fs::remove_dir_all(plugin_root); - } - - #[test] - fn worktree_create_finished_reuses_checkout_opened_before_result() { - let checkout = unique_temp_path("app-worktree-create-race-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("opened-by-race"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[1].identity_cwd = checkout.clone(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "worktree/create-race".into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - let kinds = event_kinds(&event_hub); - assert!(!kinds.contains(&crate::api::schema::EventKind::WorkspaceCreated)); - assert_eq!( - kinds - .iter() - .filter(|kind| **kind == crate::api::schema::EventKind::WorktreeCreated) - .count(), - 1 - ); - assert_eq!( - app.state.workspaces[1] - .worktree_space() - .map(|membership| membership.checkout_path.as_path()), - Some(checkout.as_path()) - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn start_worktree_add_runs_git_on_worker_and_emits_result() { - let repo = create_committed_repo("app-worktree-add-repo"); - let worktree_root = unique_temp_path("app-worktree-add-root"); - let branch = "worktree/app-worker"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_existing_branch_checks_out_branch() { - let repo = create_committed_repo("app-worktree-add-existing-branch-repo"); - let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root"); - let branch = "foo"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git(&repo, &["branch", branch]); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(checkout.join("README.md").exists()); - let branch_name = std::process::Command::new("git") - .arg("-C") - .arg(&checkout) - .args(["branch", "--show-current"]) - .output() - .unwrap(); - assert!(branch_name.status.success()); - assert_eq!( - String::from_utf8(branch_name.stdout).unwrap().trim(), - branch - ); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn open_new_worktree_dialog_supports_standalone_bare_repo_source() { - let repo = create_committed_repo("app-worktree-dialog-bare-origin"); - let bare = unique_temp_path("app-worktree-dialog-bare-repo"); - run_git( - &repo, - &["clone", "--quiet", "--bare", ".", bare.to_str().unwrap()], - ); - let worktree_root = unique_temp_path("app-worktree-dialog-bare-root"); - - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - app.state.workspaces[0].identity_cwd = bare.clone(); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::NewLinkedWorktree); - assert!(app.state.config_diagnostic.is_none()); - let create = app.state.worktree_create.as_ref().unwrap(); - assert_eq!(create.source_checkout_path, bare); - assert_eq!(create.source_repo_root, create.source_checkout_path); - let source_checkout_path = create.source_checkout_path.clone(); - - let branch = "worktree/from-bare-source"; - let repo_name = create.repo_name.clone(); - let checkout = crate::worktree::default_checkout_path(&worktree_root, &repo_name, branch); - app.state.name_input = branch.into(); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove_new = crate::worktree::build_worktree_remove_command( - &source_checkout_path, - &checkout, - false, - false, - ); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(source_checkout_path); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_uses_source_checkout_head_as_base() { - let repo = create_committed_repo("app-worktree-add-source-repo"); - let source_checkout = unique_temp_path("app-worktree-add-source-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/source-base", - source_checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap(); - run_git(&source_checkout, &["add", "SOURCE.md"]); - run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]); - - let worktree_root = unique_temp_path("app-worktree-add-from-source-root"); - let branch = "worktree/from-source"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: source_checkout.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("SOURCE.md").exists()); - - let remove_new = - crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let remove_source = - crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false, false); - crate::worktree::run_worktree_command(&remove_source).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn dirty_worktree_remove_failure_requests_force_confirmation() { - let path = std::path::PathBuf::from("/w/herdr/dirty"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err( - "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it" - .into(), - ), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert_eq!(remove.error, None); - } - - #[test] - fn non_dirty_worktree_remove_failure_keeps_error_message() { - let path = std::path::PathBuf::from("/w/herdr/missing"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err("fatal: '/w/herdr/missing' is not a working tree".into()), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(!remove.force_confirmation); - assert_eq!( - remove.error, - Some("fatal: '/w/herdr/missing' is not a working tree".into()) - ); - } - - #[test] - fn worktree_remove_finished_focuses_parent_workspace() { - let mut app = app_for_worktree_tests(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("parent"), - crate::workspace::Workspace::test_new("issue"), - crate::workspace::Workspace::test_new("sibling"), - ]; - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - app.state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.workspaces[2].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-sibling".into(), - is_linked_worktree: true, - }); - let child_id = app.state.workspaces[1].id.clone(); - let parent_id = app.state.workspaces[0].id.clone(); - app.state.active = Some(1); - app.state.selected = 1; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: child_id.clone(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: child_id, - path: checkout, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.workspaces[0].id, parent_id); - assert_eq!(app.state.workspaces[1].display_name(), "sibling"); - assert!(app.state.worktree_remove.is_none()); - } - - #[test] - fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let internal_workspace_id = app.state.workspaces[0].id.clone(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - let workspace_snapshot = app.workspace_info(0); - let worktree_snapshot = crate::api::schema::WorktreeInfo { - path: checkout.display().to_string(), - branch: Some("worktree/issue".into()), - is_bare: false, - is_detached: false, - is_prunable: false, - is_linked_worktree: true, - open_workspace_id: None, - label: "herdr".into(), - }; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: internal_workspace_id.clone(), - repo_root: "/repo/herdr".into(), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: true, - }); - app.state.workspaces.clear(); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: internal_workspace_id, - path: checkout, - workspace: Some(Box::new(workspace_snapshot.clone())), - worktree: Some(Box::new(worktree_snapshot)), - forced: true, - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![crate::api::schema::EventKind::WorktreeRemoved] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { - workspace_id, - workspace: Some(workspace), - worktree, - forced, - } if workspace_id == &workspace_snapshot.workspace_id - && workspace.workspace_id == workspace_snapshot.workspace_id - && worktree.branch.as_deref() == Some("worktree/issue") - && *forced - ) - })); - } - - #[test] - fn dirty_worktree_remove_retries_with_force_and_closes_workspace() { - let repo = create_committed_repo("app-worktree-dirty-remove-repo"); - let checkout = unique_temp_path("app-worktree-dirty-remove-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/dirty-remove", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.active = Some(0); - app.state.selected = 0; - app.open_remove_linked_worktree_confirmation(0); - - app.start_worktree_remove(); - - #[cfg(not(windows))] - { - let safe_event = wait_for_worktree_event(&mut app); - match safe_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert!(result.result.is_err()); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - } - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert!(checkout.exists()); - - app.start_worktree_remove(); - let force_event = wait_for_worktree_event(&mut app); - match force_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(!checkout.exists()); - assert!(app.state.worktree_remove.is_none()); - assert!(app.state.workspaces.is_empty()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceClosed, - crate::api::schema::EventKind::WorktreeRemoved, - ] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { worktree, .. } - if worktree.branch.as_deref() == Some("worktree/dirty-remove") - && !worktree.is_detached - ) - })); - - let _ = std::fs::remove_dir_all(repo); - } + use super::App; #[test] fn worktree_remove_runtime_shutdown_policy_preserves_windows_safe_remove() { diff --git a/src/cli/spec.rs b/src/cli/spec.rs index ed3c74a8..bcfa6c35 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -8,7 +8,6 @@ pub(super) fn command() -> Command { .disable_help_flag(true) .disable_version_flag(true) .arg(help_flag()) - .arg(flag("no-session").help("Run monolithically without server/client session mode")) .arg(option("session", "NAME").help("Use or create a named persistent session")) .arg(option("remote", "TARGET").help("Attach through SSH to a remote Herdr server")) .arg( diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index c24c9b73..525398b4 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -94,6 +94,7 @@ fn workspace_create(args: &[String]) -> std::io::Result { } super::runtime::workspace_create(WorkspaceCreateParams { + source_workspace_id: None, cwd, focus, label, diff --git a/src/client/attach.rs b/src/client/attach.rs new file mode 100644 index 00000000..e385775c --- /dev/null +++ b/src/client/attach.rs @@ -0,0 +1,582 @@ +//! Direct terminal attach input parsing and semantic actions. + +#[cfg(unix)] +use std::io; + +#[cfg(unix)] +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +#[cfg(unix)] +use super::write_to_server; +#[cfg(unix)] +use crate::ipc::LocalStream; +#[cfg(unix)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; + +#[derive(Debug, Default)] +#[cfg(windows)] +pub(super) struct AttachEscapeState; + +#[derive(Debug, Default)] +#[cfg(unix)] +pub(super) struct AttachEscapeState { + pending_prefix: Option>, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachInputAction { + Forward(Vec), + ForwardPair(Vec, Vec), + Semantic(AttachSemanticAction), + ForwardThenSemantic(Vec, AttachSemanticAction), + Detach, + None, +} + +#[derive(Debug)] +#[cfg(unix)] +pub(super) enum AttachSemanticAction { + Scroll { + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option, + row: Option, + modifiers: u8, + }, + Mouse { + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + modifiers: u8, + }, + Ignore, +} + +impl AttachEscapeState { + #[cfg(unix)] + pub(super) fn filter_input( + &mut self, + data: Vec, + viewport_rows: u16, + mouse_scroll_lines: usize, + ) -> AttachInputAction { + const PREFIX: u8 = 0x02; // Ctrl+B + + if crate::raw_input::is_complete_text_bracketed_paste(&data) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardPair(prefix, data) + } else { + AttachInputAction::Forward(data) + }; + } + + if let Some(key) = single_attach_key(&data) { + let is_prefix = key.code == crossterm::event::KeyCode::Char('b') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL; + let is_quit = key.code == crossterm::event::KeyCode::Char('q') + && key.modifiers.is_empty() + && key.kind == crossterm::event::KeyEventKind::Press; + + if let Some(mut prefix) = self.pending_prefix.take() { + if is_prefix && key.kind != crossterm::event::KeyEventKind::Press { + prefix.extend(data); + self.pending_prefix = Some(prefix); + return AttachInputAction::None; + } + if is_quit { + return AttachInputAction::Detach; + } + if is_prefix { + return AttachInputAction::Forward(data); + } + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) + { + return AttachInputAction::ForwardThenSemantic(prefix, action); + } + prefix.extend(data); + return AttachInputAction::Forward(prefix); + } + + if is_prefix && key.kind == crossterm::event::KeyEventKind::Press { + self.pending_prefix = Some(data); + return AttachInputAction::None; + } + } + + if let Some(action) = attach_scroll_action(&data, viewport_rows, mouse_scroll_lines) { + return if let Some(prefix) = self.pending_prefix.take() { + AttachInputAction::ForwardThenSemantic(prefix, action) + } else { + AttachInputAction::Semantic(action) + }; + } + + // The host framer normally supplies one complete event. Preserve the legacy + // byte path for coalesced plain input used by older terminals. + let mut output = Vec::with_capacity(data.len()); + for byte in data { + if let Some(mut prefix) = self.pending_prefix.take() { + match byte { + b'q' => return AttachInputAction::Detach, + PREFIX => output.extend(prefix), + other => { + prefix.push(other); + output.extend(prefix); + } + } + continue; + } + + if byte == PREFIX { + self.pending_prefix = Some(vec![PREFIX]); + } else { + output.push(byte); + } + } + + if output.is_empty() { + AttachInputAction::None + } else if let Some(action) = + attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) + { + AttachInputAction::Semantic(action) + } else { + AttachInputAction::Forward(output) + } + } + + #[cfg(unix)] + pub(super) fn take_pending_prefix(&mut self) -> Option> { + self.pending_prefix.take() + } +} + +#[cfg(unix)] +fn single_attach_key(data: &[u8]) -> Option { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + match events.pop()? { + crate::raw_input::RawInputEvent::Key(key) => Some(key), + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn direct_attach_pixel_mouse( + data: &[u8], + geometry: crate::input::mouse::HostGeometry, +) -> Option<( + crate::protocol::ClientMouseKind, + crate::protocol::ClientMousePosition, + u8, +)> { + let (x, y) = crate::input::mouse::parse_report(data)?; + let (column, row) = geometry.cell(x, y)?; + let cell_report = crate::input::mouse::report_at_cell(data, column, row)?; + let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 { + return None; + } + let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else { + return None; + }; + Some(( + crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, + crate::protocol::ClientMousePosition::Pixels { x, y, column, row }, + mouse.modifiers.bits(), + )) +} + +#[cfg(unix)] +fn attach_scroll_action( + data: &[u8], + viewport_rows: u16, + mouse_scroll_lines: usize, +) -> Option { + let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + if events.len() != 1 { + return None; + } + + match events.pop()? { + crate::raw_input::RawInputEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if mouse.kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::Wheel, + direction, + lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + column: Some(mouse.column), + row: Some(mouse.row), + modifiers: mouse.modifiers.bits(), + }) + } + kind => Some(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?, + position: crate::protocol::ClientMousePosition::Cell { + column: mouse.column, + row: mouse.row, + }, + modifiers: mouse.modifiers.bits(), + }), + }, + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + let direction = match key.code { + KeyCode::PageUp => AttachScrollDirection::Up, + KeyCode::PageDown => AttachScrollDirection::Down, + _ => return None, + }; + Some(AttachSemanticAction::Scroll { + source: AttachScrollSource::PageKey { + input: data.to_vec(), + }, + direction, + lines: viewport_rows.saturating_sub(1).max(1), + column: None, + row: None, + modifiers: KeyModifiers::empty().bits(), + }) + } + crate::raw_input::RawInputEvent::Key(key) + if key.modifiers.is_empty() + && key.kind == KeyEventKind::Release + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => + { + Some(AttachSemanticAction::Ignore) + } + _ => None, + } +} + +#[cfg(unix)] +pub(super) fn write_attach_semantic_action( + stream: &mut LocalStream, + action: AttachSemanticAction, +) -> io::Result<()> { + let message = match action { + AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + modifiers, + } => ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }, + AttachSemanticAction::Mouse { + kind, + position, + modifiers, + } => ClientMessage::AttachMouse { + kind, + position, + geometry: None, + modifiers, + lines: 1, + }, + AttachSemanticAction::Ignore => return Ok(()), + }; + write_to_server(stream, &message) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::protocol::{AttachScrollDirection, AttachScrollSource}; + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_sends_literal_prefix_on_double_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + match escape.filter_input(vec![0x02], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), + other => panic!("expected forwarded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_kitty_encoded_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[113u".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_detaches_on_modify_other_keys_encoded_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[27;5;98~".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"q".to_vec(), 24, 3), + AttachInputAction::Detach + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_kitty_encoded_literal_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(b"\x1b[98;5:3u".to_vec(), 24, 3), + AttachInputAction::None + )); + match escape.filter_input(b"\x1b[98;5u".to_vec(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[98;5u"), + other => panic!("expected Kitty-encoded prefix, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_does_not_interpret_bracketed_paste_contents() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); + + match escape.filter_input(paste.clone(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), + other => panic!("expected opaque paste, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(paste.clone(), 24, 3), + AttachInputAction::ForwardPair(prefix, bytes) + if prefix == vec![0x02] && bytes == paste + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"q" + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_prefix_before_non_escape_key() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![b'a', 0x02], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"a" + )); + match escape.filter_input(vec![b'x'], 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), + other => panic!("expected forwarded bytes, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_wheel_into_scroll_action() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + column, + row, + .. + }) => { + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 7); + assert_eq!(column, Some(10)); + assert_eq!(row, Some(5)); + } + other => panic!("expected scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_routes_non_wheel_mouse_reports_semantically() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::Semantic(AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + modifiers: 0, + }) + )); + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_cell_mouse() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), + AttachInputAction::ForwardThenSemantic( + prefix, + AttachSemanticAction::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: crate::protocol::ClientMousePosition::Cell { + column: 10, + row: 5 + }, + modifiers: 0, + } + ) if prefix == vec![0x02] + )); + } + + #[cfg(unix)] + #[test] + fn direct_attach_pixel_mouse_keeps_pixels_and_semantic_kind() { + let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(); + let (kind, position, modifiers) = + direct_attach_pixel_mouse(b"\x1b[<0;21;22M", geometry).expect("pixel mouse"); + + assert_eq!( + kind, + crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left) + ); + assert_eq!( + position, + crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + } + ); + assert_eq!(modifiers, 0); + } + + #[cfg(unix)] + #[test] + fn pixel_mouse_flushes_pending_attach_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert_eq!(escape.take_pending_prefix(), Some(vec![0x02])); + assert_eq!(escape.take_pending_prefix(), None); + } + + #[cfg(unix)] + #[test] + fn attach_escape_turns_plain_page_keys_into_scroll_actions() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 11); + } + other => panic!("expected page-up scroll action, got {other:?}"), + } + + match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { + AttachInputAction::Semantic(AttachSemanticAction::Scroll { + source, + direction, + lines, + .. + }) => { + assert_eq!( + source, + AttachScrollSource::PageKey { + input: b"\x1b[6~".to_vec() + } + ); + assert_eq!(direction, AttachScrollDirection::Down); + assert_eq!(lines, 11); + } + other => panic!("expected page-down scroll action, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_forwards_modified_page_key() { + let mut escape = AttachEscapeState::default(); + match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), + other => panic!("expected modified page key to forward, got {other:?}"), + } + } +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs new file mode 100644 index 00000000..c1c49cbc --- /dev/null +++ b/src/client/clipboard_images.rs @@ -0,0 +1,248 @@ +use std::path::PathBuf; + +use tracing::{info, warn}; + +use crate::ipc::LocalStream; +#[cfg(windows)] +use crate::protocol::ClientInputEvent; +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; +use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; + +use super::{is_remote_client_process, write_to_server, ClientError}; + +pub(super) fn write_remote_image_to_server( + stream: &mut LocalStream, + target: ClientClipboardImageTarget, + image: crate::platform::ClipboardImage, + source: &'static str, +) -> Result<(), ClientError> { + if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + bytes = image.bytes.len(), + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + source, + "local image is too large to bridge" + ); + return Ok(()); + } + + info!( + bytes = image.bytes.len(), + extension = image.extension, + source, + "bridging local image to remote server" + ); + write_to_server( + stream, + &ClientMessage::ClipboardImage { + target, + extension: image.extension.to_owned(), + data: image.bytes, + }, + ) + .map_err(ClientError::ConnectionLost) +} + +pub(super) fn client_remote_image_paste_key( + config: &crate::config::Config, +) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { + if !is_remote_client_process() { + return None; + } + + match config.remote_image_paste_key() { + Ok(key) => key, + Err(diagnostic) => { + warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); + None + } + } +} + +#[cfg(unix)] +pub(super) fn should_bridge_clipboard_image_paste( + data: &[u8], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if data == b"\x1b[200~\x1b[201~" { + return is_remote_client; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + + let events = crate::raw_input::parse_raw_input_bytes_sync(data); + matches!( + events.as_slice(), + [crate::raw_input::RawInputEvent::Key(key)] + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) + ) +} + +#[cfg(windows)] +pub(super) fn should_bridge_clipboard_image_events( + events: &[ClientInputEvent], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if !is_remote_client { + return false; + } + if matches!(events, [ClientInputEvent::Paste { text }] if text.is_empty()) { + return true; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + matches!( + events, + [event] + if matches!( + event.to_raw_input_event(), + crate::raw_input::RawInputEvent::Key(key) + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo( + &key, + remote_image_paste_key, + ) + ) + ) +} + +#[cfg(unix)] +pub(super) fn read_image_file_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option { + let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + read_image_file(path, extension) +} + +#[cfg(windows)] +pub(super) fn read_image_file_from_client_events( + events: &[ClientInputEvent], + is_remote_client: bool, +) -> Option { + let [ClientInputEvent::Paste { text }] = events else { + return None; + }; + let text = normalized_terminal_drop_text(text)?; + let (path, extension) = + image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; + read_image_file(path, extension) +} + +fn read_image_file( + path: PathBuf, + extension: &'static str, +) -> Option { + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() { + return None; + } + + let file = std::fs::File::open(&path).ok()?; + let bytes = + match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { + crate::platform::LimitedRead::Complete(bytes) => bytes, + crate::platform::LimitedRead::Empty => return None, + crate::platform::LimitedRead::Oversized => { + warn!( + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + "local image file drop is too large to bridge" + ); + return None; + } + }; + + Some(crate::platform::ClipboardImage { bytes, extension }) +} + +#[cfg(unix)] +pub(super) fn image_path_from_terminal_drop( + data: &[u8], + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + let bytes = bracketed_paste_payload(data).unwrap_or(data); + let text = std::str::from_utf8(bytes).ok()?; + let text = normalized_terminal_drop_text(text)?; + let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); + image_path_from_drop_text(&text, is_remote_client) +} + +fn normalized_terminal_drop_text(text: &str) -> Option<&str> { + let text = text.trim_end_matches(['\r', '\n']); + (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) +} + +fn image_path_from_drop_text( + text: &str, + is_remote_client: bool, +) -> Option<(PathBuf, &'static str)> { + if !is_remote_client { + return None; + } + let path = PathBuf::from(text); + if !path.is_absolute() { + return None; + } + let extension = recognized_image_extension(path.extension()?.to_str()?)?; + Some((path, extension)) +} + +#[cfg(unix)] +fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { + const START: &[u8] = b"\x1b[200~"; + const END: &[u8] = b"\x1b[201~"; + data.strip_prefix(START)?.strip_suffix(END) +} + +fn strip_matching_path_quotes(text: &str) -> &str { + if text.len() < 2 { + return text; + } + + let bytes = text.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], + _ => text, + } +} + +#[cfg(unix)] +fn unescape_terminal_drop_path(text: &str) -> String { + let mut unescaped = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(escaped) = chars.next() { + unescaped.push(escaped); + } else { + unescaped.push(ch); + } + } else { + unescaped.push(ch); + } + } + unescaped +} + +fn recognized_image_extension(extension: &str) -> Option<&'static str> { + if extension.eq_ignore_ascii_case("png") { + Some("png") + } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { + Some("jpg") + } else if extension.eq_ignore_ascii_case("gif") { + Some("gif") + } else if extension.eq_ignore_ascii_case("webp") { + Some("webp") + } else if extension.eq_ignore_ascii_case("bmp") { + Some("bmp") + } else { + None + } +} diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs new file mode 100644 index 00000000..decb8522 --- /dev/null +++ b/src/client/endpoint_commands.rs @@ -0,0 +1,224 @@ +use std::collections::VecDeque; +use std::io; + +use crate::api::client::ApiClientError; +use crate::api::schema::{Request, ResponseResult}; +use crate::ipc::LocalStream; +use crate::protocol::ClientMessage; + +use super::shell::ClientShellEndpointError; + +struct InFlightCommand { + boot_id: String, + request_id: String, + response: Vec, +} + +pub(super) struct EndpointCommandResult { + pub(super) boot_id: String, + pub(super) request_id: String, + pub(super) result: Result, +} + +#[derive(Default)] +pub(super) struct EndpointCommands { + queued: VecDeque<(String, Box)>, + in_flight: Option, +} + +impl EndpointCommands { + pub(super) fn enqueue(&mut self, boot_id: String, request: Box) { + self.queued.push_back((boot_id, request)); + } + + pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> { + if self.in_flight.is_some() { + return Ok(()); + } + let Some((boot_id, request)) = self.queued.pop_front() else { + return Ok(()); + }; + let request_id = request.id.clone(); + let request = serde_json::to_string(&request) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + super::write_to_server( + stream, + &ClientMessage::ClientShellEndpointRequest { + boot_id: boot_id.clone(), + request, + }, + )?; + self.in_flight = Some(InFlightCommand { + boot_id, + request_id, + response: Vec::new(), + }); + Ok(()) + } + + pub(super) fn receive_chunk( + &mut self, + response_boot_id: &str, + response_request_id: &str, + final_chunk: bool, + data: Vec, + ) -> io::Result> { + let Some(in_flight) = self.in_flight.as_mut() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "endpoint response arrived without an in-flight command", + )); + }; + if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "endpoint response correlation did not match the in-flight command", + )); + } + in_flight.response.extend(data); + if !final_chunk { + return Ok(None); + } + + let in_flight = self.in_flight.take().expect("checked in-flight command"); + let response = String::from_utf8(in_flight.response) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let result = parse_response(&in_flight.request_id, &response); + Ok(Some(EndpointCommandResult { + boot_id: in_flight.boot_id, + request_id: in_flight.request_id, + result, + })) + } +} + +fn parse_response( + expected_id: &str, + response: &str, +) -> Result { + let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError { + code: None, + message: format!("invalid endpoint response: {error}"), + })?; + match crate::api::client::parse_response_value(value) { + Ok(response) if response.id == expected_id => Ok(response.result), + Ok(response) => Err(ClientShellEndpointError { + code: None, + message: format!( + "endpoint response id {:?} did not match {expected_id:?}", + response.id + ), + }), + Err(ApiClientError::ErrorResponse(response)) if response.id == expected_id => { + Err(ClientShellEndpointError { + code: Some(response.error.code), + message: response.error.message, + }) + } + Err(ApiClientError::ErrorResponse(response)) => Err(ClientShellEndpointError { + code: None, + message: format!( + "endpoint error id {:?} did not match {expected_id:?}", + response.id + ), + }), + Err(error) => Err(ClientShellEndpointError { + code: None, + message: error.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{ResponseResult, SuccessResponse}; + + fn commands_with_in_flight() -> EndpointCommands { + EndpointCommands { + in_flight: Some(InFlightCommand { + boot_id: "boot-a".into(), + request_id: "request-a".into(), + response: Vec::new(), + }), + ..EndpointCommands::default() + } + } + + #[test] + fn chunked_response_completion_is_correlated_and_clears_the_lane() { + let mut commands = commands_with_in_flight(); + let response = serde_json::to_string(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + let split = response.len() / 2; + + assert!(commands + .receive_chunk( + "boot-a", + "request-a", + false, + response.as_bytes()[..split].to_vec(), + ) + .unwrap() + .is_none()); + let completed = commands + .receive_chunk( + "boot-a", + "request-a", + true, + response.as_bytes()[split..].to_vec(), + ) + .unwrap() + .unwrap(); + + assert_eq!(completed.boot_id, "boot-a"); + assert_eq!(completed.request_id, "request-a"); + assert!(matches!(completed.result, Ok(ResponseResult::Ok {}))); + assert!(commands.in_flight.is_none()); + } + + #[test] + fn large_selection_response_reassembles_without_truncation() { + let mut commands = commands_with_in_flight(); + let selection = "selected".repeat(160_000); + let response = serde_json::to_vec(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::PaneSelection { + pane_id: "w1:p1".into(), + text: selection.clone(), + }, + }) + .unwrap(); + let chunk_count = response.len().div_ceil(128 * 1024); + let mut completed = None; + for (index, chunk) in response.chunks(128 * 1024).enumerate() { + completed = commands + .receive_chunk( + "boot-a", + "request-a", + index + 1 == chunk_count, + chunk.to_vec(), + ) + .unwrap(); + } + + assert!(matches!( + completed.expect("final selection response").result, + Ok(ResponseResult::PaneSelection { text, .. }) if text == selection + )); + } + + #[test] + fn response_from_another_boot_is_rejected() { + let mut commands = commands_with_in_flight(); + + let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else { + panic!("mismatched boot should fail"); + }; + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/src/client/errors.rs b/src/client/errors.rs new file mode 100644 index 00000000..460ba21f --- /dev/null +++ b/src/client/errors.rs @@ -0,0 +1,99 @@ +use std::io; + +use crate::protocol; +use crate::server::socket_paths::client_socket_path; + +/// Errors that can occur during client operation. +#[derive(Debug)] +pub enum ClientError { + /// Could not connect to the server's client socket. + ConnectionFailed(io::Error), + /// Server rejected our handshake. + HandshakeRejected { version: u32, error: String }, + /// Server shut down. + ServerShutdown { reason: Option }, + /// Lost connection to the server. + ConnectionLost(io::Error), + /// Protocol error (framing, deserialization). + Protocol(protocol::FramingError), +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::ConnectionFailed(err) => { + write!(f, "failed to connect to server: {err}")?; + let path = client_socket_path(); + write!( + f, + "\nIs herdr server running? Start it with `herdr server`." + )?; + write!(f, "\nSocket path: {}", path.display()) + } + ClientError::HandshakeRejected { version, error } => { + write!(f, "server rejected handshake (version {version}): {error}") + } + ClientError::ServerShutdown { reason } => { + match reason.as_deref() { + Some("detached") => { + if let Ok(reattach_command) = + std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "detached from remote server")?; + write!(f, "\nRun `{reattach_command}` to reattach")?; + } else { + write!(f, "detached from server")?; + write!( + f, + "\nRun `{}` to reattach", + crate::session::local_attach_command() + )?; + } + } + _ => { + write!(f, "server shut down")?; + if let Some(reason) = reason { + write!(f, ": {reason}")?; + } + } + } + Ok(()) + } + ClientError::ConnectionLost(err) => { + if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) + { + write!(f, "lost connection to remote Herdr: {err}")?; + write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; + write!(f, "\nRun `{reattach_command}` to reattach") + } else { + write!(f, "lost connection to server: {err}") + } + } + ClientError::Protocol(err) => write!(f, "protocol error: {err}"), + } + } +} + +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::ConnectionFailed(err) => Some(err), + ClientError::ConnectionLost(err) => Some(err), + ClientError::Protocol(err) => Some(err), + _ => None, + } + } +} + +impl From for ClientError { + fn from(err: protocol::FramingError) -> Self { + match err { + protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( + io::ErrorKind::UnexpectedEof, + "server closed connection", + )), + protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), + err => ClientError::Protocol(err), + } + } +} diff --git a/src/client/frame_output.rs b/src/client/frame_output.rs new file mode 100644 index 00000000..5d12c78c --- /dev/null +++ b/src/client/frame_output.rs @@ -0,0 +1,97 @@ +use std::collections::HashSet; +use std::io; +use std::sync::{Mutex, OnceLock}; + +use crate::protocol::render_ansi; + +static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::new(); + +pub(super) fn write_encoded_frame_with_graphics( + mut writer: impl io::Write, + encoded: &[u8], + graphics: &[u8], +) -> io::Result<()> { + if graphics.is_empty() { + return writer.write_all(encoded); + } + + let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); + + writer.write_all(&encoded[..insertion])?; + record_received_kitty_graphics(graphics); + writer.write_all(b"\x1b7")?; + writer.write_all(graphics)?; + writer.write_all(b"\x1b8")?; + writer.write_all(&encoded[insertion..]) +} + +pub(super) fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { + bytes.windows(3).any(|window| window == b"\x1b_G") +} + +pub(super) fn record_received_kitty_graphics(bytes: &[u8]) { + let ids = kitty_graphics_image_ids(bytes); + if ids.is_empty() { + return; + } + let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut set) = set.lock() { + set.extend(ids); + } +} + +pub(super) fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { + let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { + return Ok(()); + }; + let Ok(mut set) = set.lock() else { + return Ok(()); + }; + for id in set.drain() { + write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; + } + writer.flush() +} + +pub(super) fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec { + let mut ids = Vec::new(); + let mut index = 0usize; + while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { + let command_start = index + start + 3; + let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { + break; + }; + let command = &bytes[command_start..command_start + end]; + if let Some(id) = kitty_graphics_command_image_id(command) { + ids.push(id); + } + index = command_start + end + 2; + } + ids +} + +fn kitty_graphics_command_image_id(command: &[u8]) -> Option { + let header_end = command + .iter() + .position(|byte| *byte == b';') + .unwrap_or(command.len()); + for part in command[..header_end].split(|byte| *byte == b',') { + let Some(value) = part.strip_prefix(b"i=") else { + continue; + }; + let text = std::str::from_utf8(value).ok()?; + if let Ok(id) = text.parse::() { + return Some(id); + } + } + None +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs new file mode 100644 index 00000000..2140516e --- /dev/null +++ b/src/client/handshake.rs @@ -0,0 +1,189 @@ +use std::io; +#[cfg(unix)] +use std::io::IsTerminal as _; +use std::time::Duration; + +use interprocess::local_socket::traits::Stream as _; +#[cfg(windows)] +use tracing::debug; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, +}; + +#[cfg(unix)] +use super::terminal_setup::is_ssh_session; +use super::{shell, ClientError}; + +/// Time to wait for the server's Welcome reply during the handshake. +/// +/// A local client talks to an already-connected server, so 5s is plenty. The +/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh +/// connection whose cold-connect (TCP + key exchange + auth) happens inside this +/// window; on a high-latency link that easily exceeds 5s, so it gets a far +/// larger budget. See issue #753. +pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) fn is_remote_client_process() -> bool { + std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() +} + +pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource { + match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) + .ok() + .as_deref() + { + Some("server") => shell::ClientShellKeybindingSource::Endpoint, + Some(_) => shell::ClientShellKeybindingSource::RemoteLocal, + None => shell::ClientShellKeybindingSource::Local, + } +} + +pub(super) fn handshake_read_timeout() -> Duration { + if is_remote_client_process() { + return REMOTE_HANDSHAKE_READ_TIMEOUT; + } + LOCAL_HANDSHAKE_READ_TIMEOUT +} + +#[cfg(any(unix, test))] +pub(super) fn direct_graphics_profile_values( + term_program: &str, + term: &str, + kitty_window: bool, + blocked_transport: bool, + terminals: bool, +) -> bool { + let supported = term_program.eq_ignore_ascii_case("ghostty") + || term_program.eq_ignore_ascii_case("wezterm") + || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") + || kitty_window; + supported && !blocked_transport && terminals +} + +#[cfg(unix)] +fn direct_graphics_profile_allowed() -> bool { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + direct_graphics_profile_values( + &term_program, + &term, + std::env::var_os("KITTY_WINDOW_ID").is_some(), + is_remote_client_process() + || is_ssh_session() + || std::env::var_os("TMUX").is_some() + || std::env::var_os("STY").is_some(), + io::stdin().is_terminal() && io::stdout().is_terminal(), + ) +} + +#[cfg(not(unix))] +fn direct_graphics_profile_allowed() -> bool { + false +} + +#[cfg(windows)] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + context: &'static str, +) -> Result<(), ClientError> { + match stream.set_recv_timeout(timeout) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported => { + debug!(err = %err, context, "client socket receive timeout unavailable"); + Ok(()) + } + Err(err) => Err(ClientError::ConnectionFailed(err)), + } +} + +#[cfg(not(windows))] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + _context: &'static str, +) -> Result<(), ClientError> { + stream + .set_recv_timeout(timeout) + .map_err(ClientError::ConnectionFailed) +} + +/// Performs the client→server handshake. +/// +/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol +/// version, then reads the Welcome response. +pub(super) fn do_handshake( + stream: &mut LocalStream, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + exact_cell_size: bool, + shell_surface_size: Option, + endpoint_keybindings: bool, + mouse_capture: bool, +) -> Result { + stream + .set_nonblocking(false) + .map_err(ClientError::ConnectionFailed)?; + + let hello = if let Some(surface_size) = shell_surface_size { + ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse: exact_cell_size && cfg!(unix), + direct_graphics: exact_cell_size + && cell_width_px > 0 + && cell_height_px > 0 + && direct_graphics_profile_allowed(), + endpoint_keybindings, + mouse_capture, + } + } else { + ClientMessage::TerminalHello { + version: PROTOCOL_VERSION, + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: exact_cell_size && cfg!(unix), + } + }; + protocol::write_message(stream, &hello) + .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + + set_handshake_recv_timeout( + stream, + Some(handshake_read_timeout()), + "client handshake read timeout unavailable", + )?; + let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; + set_handshake_recv_timeout( + stream, + None, + "failed to clear client handshake read timeout", + )?; + + match welcome { + ServerMessage::Welcome { + version, + encoding, + error, + } => { + if let Some(error) = error { + return Err(ClientError::HandshakeRejected { version, error }); + } + info!(version, ?encoding, "handshake succeeded"); + Ok(encoding) + } + _ => Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), + ))), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 53e818d7..12efa8e3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,7 +1,7 @@ //! Thin client mode — connects to the server's client socket. //! //! The client: -//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version +//! - Connects to `herdr-client.sock`, sends TerminalHello with terminal size and protocol version //! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements) //! - Receives Frame messages and blits them to the terminal (diff against last frame) //! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input @@ -12,45 +12,105 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server +mod attach; +mod clipboard_images; #[cfg(unix)] mod direct_graphics; +mod endpoint_commands; +mod errors; +mod frame_output; +mod handshake; mod input; +mod notifications; +mod shell; +mod terminal_geometry; +mod terminal_sessions; +mod terminal_setup; +mod timer; -use std::collections::HashSet; -#[cfg(unix)] -use std::io::IsTerminal as _; -use std::io::{self, BufRead, Write as _}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +#[cfg(test)] +pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; -use base64::Engine; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, +#[cfg(not(windows))] +use terminal_geometry::query_host_terminal_appearance; +#[cfg(test)] +use terminal_geometry::{ + cell_size_fallback, ioctl_cell_size, pack_cell_size, resize_report_required, + should_query_host_cell_size, write_host_cell_size_query, write_host_terminal_appearance_query, + write_host_terminal_theme_query, +}; +use terminal_geometry::{ + host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, + query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, }; #[cfg(unix)] -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; -use crossterm::execute; -use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; +use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; +use terminal_setup::{ + effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, + setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, +}; +#[cfg(windows)] +use terminal_setup::{ + enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, +}; +#[cfg(test)] +use terminal_setup::{ + should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, + write_host_color_scheme_report_mode, write_terminal_restore_postlude, +}; + +#[cfg(unix)] +use attach::direct_attach_pixel_mouse; +use attach::AttachEscapeState; +#[cfg(unix)] +use attach::{write_attach_semantic_action, AttachInputAction}; +use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +#[cfg(windows)] +use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; +#[cfg(unix)] +use clipboard_images::{read_image_file_from_terminal_drop, should_bridge_clipboard_image_paste}; +pub use errors::ClientError; +#[cfg(test)] +use frame_output::{clear_received_kitty_graphics, kitty_graphics_image_ids}; +use frame_output::{ + contains_kitty_graphics_bytes, record_received_kitty_graphics, + write_encoded_frame_with_graphics, +}; +use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; +#[cfg(test)] +use handshake::{ + direct_graphics_profile_values, handshake_read_timeout, REMOTE_HANDSHAKE_READ_TIMEOUT, +}; +use notifications::{handle_notify, handle_shell_notification_effects}; +#[cfg(test)] +use notifications::{handle_notify_with_notifiers, sound_from_notify_message}; +#[cfg(test)] +use terminal_sessions::terminal_control_command_from_json; + +#[cfg(unix)] +use std::collections::HashMap; +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +#[cfg(unix)] +use std::sync::Mutex; +use std::time::Duration; + use interprocess::local_socket::traits::Stream as _; use interprocess::TryClone as _; use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, - ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, }; +#[cfg(test)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::new(); - // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- @@ -61,8 +121,11 @@ struct ClientLoopConfig { redraw_on_focus_gained: bool, host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, mouse_capture_active: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + shell_config: Option, } /// State tracking for the thin client. @@ -71,21 +134,42 @@ struct ClientState { blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, - /// Whether the host terminal currently reports all keys as Kitty sequences. + /// Last mouse-capture demand published by the endpoint. + endpoint_mouse_capture_requested: bool, + /// Last exact-pixel mouse demand published by the endpoint. + endpoint_sgr_pixels_requested: bool, + /// Client-local direct-attach preference, combined with child mouse demand. + direct_mouse_capture_preference: bool, + /// Client-owned shell mouse-capture preference. + shell_mouse_capture_preference: bool, + /// Host keyboard protocol state currently owned for a direct terminal attach. + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + /// Focused endpoint pane/popup demand for Kitty report-all input. + pane_keyboard_report_all: bool, + /// Whether the client-owned shell currently enabled host report-all input. keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last Hello/Resize. + /// The terminal size we reported to the server in our last handshake/Resize. reported_size: (u16, u16), + /// Last exact host cell size used by client-rendered surfaces. + reported_cell_size: (u32, u32), /// Client-local sound playback config, refreshed on server request. sound_config: crate::config::SoundConfig, /// Whether this client may write Kitty graphics bytes to its host terminal. kitty_graphics_enabled: bool, + /// Whether resize reports inspect host pixel geometry. + pixel_geometry_enabled: bool, + /// Whether the latest host pixel geometry is exact enough for pixel mouse input. + pixel_geometry_exact: bool, /// One bounded matcher, inactive unless a direct transmission is armed. #[cfg(unix)] direct_graphics_response: Arc>, /// One server-retired direct transfer to suppress if it was still queued. #[cfg(unix)] retired_direct_graphics: Option<(u64, u32)>, - /// Direct attach prefix escape state. None for full-app clients. + /// ClientShell assets waiting for the host terminal's direct-upload response. + #[cfg(unix)] + pending_surface_graphics: HashMap, + /// Direct attach prefix escape state. None for ClientShell connections. attach_escape: Option, /// Rows scrolled for one direct-attach wheel notch. #[cfg(unix)] @@ -98,142 +182,22 @@ struct ClientState { repaint_pending: bool, /// Whether this client draws the cursor into frame cells instead of using the host cursor. draw_host_cursor: bool, + /// Browser opener processes launched by client-owned link activation. + detached_process_children: Vec, + /// Experimental client-owned shell state. + shell: Option, } -#[derive(Debug, Default)] -#[cfg(windows)] -struct AttachEscapeState; - -#[derive(Debug, Default)] -#[cfg(unix)] -struct AttachEscapeState { - pending_prefix: bool, -} - -#[derive(Debug)] -#[cfg(unix)] -enum AttachInputAction { - Forward(Vec), - ForwardAfterPendingPrefix(Vec), - Scroll { - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option, - row: Option, - modifiers: u8, - }, - Detach, - None, -} - -impl AttachEscapeState { - #[cfg(unix)] - fn filter_input( - &mut self, - data: Vec, - viewport_rows: u16, - mouse_scroll_lines: usize, - ) -> AttachInputAction { - const PREFIX: u8 = 0x02; // Ctrl+B - - if crate::raw_input::is_complete_text_bracketed_paste(&data) { - return if std::mem::take(&mut self.pending_prefix) { - AttachInputAction::ForwardAfterPendingPrefix(data) - } else { - AttachInputAction::Forward(data) - }; +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); } - - let mut output = Vec::with_capacity(data.len()); - for byte in data { - if self.pending_prefix { - self.pending_prefix = false; - match byte { - b'q' => return AttachInputAction::Detach, - PREFIX => output.push(PREFIX), - other => { - output.push(PREFIX); - output.push(other); - } - } - continue; - } - - if byte == PREFIX { - self.pending_prefix = true; - } else { - output.push(byte); - } - } - - if output.is_empty() { - AttachInputAction::None - } else if let Some(action) = - attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) - { - action - } else { - AttachInputAction::Forward(output) - } - } -} - -#[cfg(unix)] -fn attach_scroll_action( - data: &[u8], - viewport_rows: u16, - mouse_scroll_lines: usize, -) -> Option { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); - if events.len() != 1 { - return None; - } - - match events.pop()? { - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let direction = match mouse.kind { - MouseEventKind::ScrollUp => AttachScrollDirection::Up, - MouseEventKind::ScrollDown => AttachScrollDirection::Down, - _ => return Some(AttachInputAction::None), - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::Wheel, - direction, - lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, - column: Some(mouse.column), - row: Some(mouse.row), - modifiers: mouse.modifiers.bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => - { - let direction = match key.code { - KeyCode::PageUp => AttachScrollDirection::Up, - KeyCode::PageDown => AttachScrollDirection::Down, - _ => return None, - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::PageKey { - input: data.to_vec(), - }, - direction, - lines: viewport_rows.saturating_sub(1).max(1), - column: None, - row: None, - modifiers: KeyModifiers::empty().bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && key.kind == KeyEventKind::Release - && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => - { - Some(AttachInputAction::None) - } - _ => None, } } @@ -241,666 +205,81 @@ impl ClientState { fn request_repaint(&mut self) { self.repaint_pending = true; } -} -// --------------------------------------------------------------------------- -// Error types -// --------------------------------------------------------------------------- - -/// Errors that can occur during client operation. -#[derive(Debug)] -pub enum ClientError { - /// Could not connect to the server's client socket. - ConnectionFailed(io::Error), - /// Server rejected our handshake. - HandshakeRejected { version: u32, error: String }, - /// Server shut down. - ServerShutdown { reason: Option }, - /// Lost connection to the server. - ConnectionLost(io::Error), - /// Protocol error (framing, deserialization). - Protocol(protocol::FramingError), -} - -impl std::fmt::Display for ClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ClientError::ConnectionFailed(err) => { - write!(f, "failed to connect to server: {err}")?; - let path = client_socket_path(); - write!( - f, - "\nIs herdr server running? Start it with `herdr server`." - )?; - write!(f, "\nSocket path: {}", path.display()) - } - ClientError::HandshakeRejected { version, error } => { - write!(f, "server rejected handshake (version {version}): {error}") - } - ClientError::ServerShutdown { reason } => { - match reason.as_deref() { - Some("detached") => { - if let Ok(reattach_command) = - std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "detached from remote server")?; - write!(f, "\nRun `{reattach_command}` to reattach")?; - } else { - write!(f, "detached from server")?; - write!( - f, - "\nRun `{}` to reattach", - crate::session::local_attach_command() - )?; - } - } - _ => { - write!(f, "server shut down")?; - if let Some(reason) = reason { - write!(f, ": {reason}")?; - } - } - } - Ok(()) - } - ClientError::ConnectionLost(err) => { - if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "lost connection to remote Herdr: {err}")?; - write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; - write!(f, "\nRun `{reattach_command}` to reattach") - } else { - write!(f, "lost connection to server: {err}") - } - } - ClientError::Protocol(err) => { - write!(f, "protocol error: {err}") - } + fn present_graphics(&mut self, graphics: &[u8]) { + if graphics.is_empty() || !self.kitty_graphics_enabled { + return; } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); } -} -impl std::error::Error for ClientError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ClientError::ConnectionFailed(err) => Some(err), - ClientError::ConnectionLost(err) => Some(err), - ClientError::Protocol(err) => Some(err), - _ => None, + fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result { + if self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); } - } -} - -impl From for ClientError { - fn from(err: protocol::FramingError) -> Self { - match err { - protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - )), - protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), - err => ClientError::Protocol(err), - } - } -} - -// --------------------------------------------------------------------------- -// Terminal setup / restore -// --------------------------------------------------------------------------- - -/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). -/// -/// Returns a guard that restores the terminal when dropped. -fn setup_terminal(mouse_capture: bool) -> io::Result { - setup_terminal_with_capabilities(true, mouse_capture) -} - -/// Sets up a direct attach terminal. -/// -/// Direct attach forwards stdin to the attached PTY. When configured, mouse -/// capture lets wheel events drive the attached viewport or reach child -/// programs that requested mouse input. -fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result { - setup_terminal_with_capabilities(false, mouse_capture) -} - -fn setup_terminal_with_capabilities( - enable_client_protocols: bool, - mouse_capture: bool, -) -> io::Result { - ratatui::init(); - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - let host_color_scheme_reports = - should_enable_host_color_scheme_reports(enable_client_protocols); - - #[cfg(windows)] - let windows_ssh_session = is_ssh_session(); - #[cfg(windows)] - let mut windows_virtual_terminal_input = - if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input() + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows } else { - WindowsVirtualTerminalInputSetup::default() + patch.rows }; - - if enable_client_protocols { - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; - if host_color_scheme_reports { - write_host_color_scheme_report_mode(&mut io::stdout(), true)?; - } - push_keyboard_enhancement_flags()?; - } else { - if should_query_host_terminal_theme() { - write_host_color_scheme_report_mode(&mut io::stdout(), false)?; - } - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste)?; - } - - #[cfg(windows)] - if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { - windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); - } - - #[cfg(windows)] - if enable_client_protocols - && windows_vti_input_backend_enabled() - && windows_virtual_terminal_input.active - && windows_win32_input_mode_enabled() - { - if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { - if let Some(mode) = windows_virtual_terminal_input.restore_mode { - restore_windows_input_mode_value(mode); - } - return Err(err); - } - } - - let modify_other_keys_mode = enable_client_protocols - .then(crate::input::host_modify_other_keys_mode) - .flatten(); - if let Some(mode) = modify_other_keys_mode { - io::stdout().write_all(mode.set_sequence())?; - io::stdout().flush()?; - } - - execute!(io::stdout(), DisableLineWrap)?; - - Ok(TerminalGuard { - reset_modify_other_keys: modify_other_keys_mode.is_some(), - reset_host_color_scheme_reports: host_color_scheme_reports, - restored: false, - #[cfg(windows)] - restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, - }) -} - -fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() -} - -/// Guard that restores the terminal when dropped. -struct TerminalGuard { - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - restored: bool, - #[cfg(windows)] - restore_windows_input_mode: Option, -} - -fn write_host_color_scheme_report_mode( - writer: &mut impl io::Write, - enabled: bool, -) -> io::Result<()> { - let sequence = if enabled { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE - } else { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE - }; - writer.write_all(sequence.as_bytes())?; - writer.flush() -} - -fn write_terminal_restore_postlude( - writer: &mut impl io::Write, - reset_host_color_scheme_reports: bool, -) -> io::Result<()> { - if reset_host_color_scheme_reports { - writer.write_all( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - )?; - } - // Restore a visible cursor and reset DECSCUSR back to the terminal default. - writer.write_all(b"\x1b[?25h\x1b[0 q")?; - writer.flush() -} - -fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { - match mode { - crate::config::HostCursorModeConfig::Auto => { - crate::platform::should_draw_host_cursor_by_default() - } - crate::config::HostCursorModeConfig::Native => false, - crate::config::HostCursorModeConfig::Drawn => true, - } -} - -#[cfg(windows)] -#[derive(Default)] -struct WindowsVirtualTerminalInputSetup { - active: bool, - restore_mode: Option, -} - -#[cfg(windows)] -fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{ - GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, - STD_INPUT_HANDLE, - }; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - tracing::warn!("failed to get Windows console input handle for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut mode = 0; - if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - tracing::warn!("failed to read Windows console input mode for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let desired = windows_virtual_terminal_input_mode(mode); - if desired == mode { - return WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: None, + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) } - if unsafe { SetConsoleMode(handle, desired) } == 0 { - tracing::warn!("failed to enable Windows virtual terminal input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut applied = 0; - if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { - tracing::warn!("failed to verify Windows virtual terminal input mode"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { - tracing::warn!("Windows virtual terminal input bit did not stick"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - - WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: Some(mode), - } -} - -#[cfg(windows)] -fn windows_vti_input_backend_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_BACKEND") - .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) - .unwrap_or(true) -} - -#[cfg(any(windows, test))] -fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { - mode | 0x0200 -} - -#[cfg(windows)] -fn restore_windows_input_mode_value(mode: u32) { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return; - } - if unsafe { SetConsoleMode(handle, mode) } == 0 { - tracing::warn!("failed to restore Windows console input mode"); - } -} - -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( - &mut io::stdout(), - enabled, - sgr_pixels, - ); - } - if enabled { - execute!(io::stdout(), EnableMouseCapture)?; - if sgr_pixels { - io::stdout().write_all(b"\x1b[?1016h")?; - io::stdout().flush()?; - } - Ok(()) - } 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), - } - } -} - -fn restore_terminal_state( - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - #[cfg(windows)] restore_windows_input_mode: Option, -) -> io::Result<()> { - let _ = clear_received_kitty_graphics(&mut io::stdout()); - - // Reset modifyOtherKeys if we enabled it. - if reset_modify_other_keys { - let _ = io::stdout().write_all(b"\x1b[>4;0m"); - let _ = io::stdout().flush(); - } - - let _ = pop_keyboard_enhancement_flags(); - - let _ = execute!( - io::stdout(), - EnableLineWrap, - DisableFocusChange, - DisableBracketedPaste - ); - let _ = set_mouse_capture(false, false); - #[cfg(windows)] - if let Some(mode) = restore_windows_input_mode { - restore_windows_input_mode_value(mode); - } - - let restore_result = ratatui::try_restore(); - let postlude_result = - write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); - - #[cfg(windows)] - if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { - let _ = disable_windows_win32_input_mode(&mut io::stdout()); - } - - restore_result.and(postlude_result) -} - -#[cfg(not(windows))] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - execute!( - io::stdout(), - PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) - ) -} - -#[cfg(windows)] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(not(windows))] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - execute!(io::stdout(), PopKeyboardEnhancementFlags) -} - -#[cfg(windows)] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(windows)] -fn windows_win32_input_mode_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_PROBE") - .map(|probe| probe.eq_ignore_ascii_case("win32")) - .unwrap_or(true) -} - -#[cfg(windows)] -fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001h")?; - writer.flush() -} - -#[cfg(windows)] -fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001l")?; - writer.flush() -} - -impl TerminalGuard { - fn restore(mut self) -> io::Result<()> { - self.restored = true; - restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - if !self.restored { - let _ = restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ); - } - } -} - -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -fn requested_render_encoding() -> RenderEncoding { - match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() { - Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi, - _ => RenderEncoding::SemanticFrame, - } -} - -fn is_remote_client_process() -> bool { - std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() -} - -fn is_ssh_session() -> bool { - std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() -} - -/// Time to wait for the server's Welcome reply during the handshake. -/// -/// A local client talks to an already-connected server, so 5s is plenty. The -/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh -/// connection whose cold-connect (TCP + key exchange + auth) happens inside this -/// window; on a high-latency link that easily exceeds 5s, so it gets a far -/// larger budget. See issue #753. -const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); - -fn handshake_read_timeout() -> Duration { - if is_remote_client_process() { - return REMOTE_HANDSHAKE_READ_TIMEOUT; - } - LOCAL_HANDSHAKE_READ_TIMEOUT -} - -#[cfg(any(unix, test))] -fn direct_graphics_profile_values( - term_program: &str, - term: &str, - kitty_window: bool, - blocked_transport: bool, - terminals: bool, -) -> bool { - let supported = term_program.eq_ignore_ascii_case("ghostty") - || term_program.eq_ignore_ascii_case("wezterm") - || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") - || kitty_window; - supported && !blocked_transport && terminals -} - -#[cfg(unix)] -fn direct_graphics_profile_allowed(direct_attach: bool) -> bool { - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let term = std::env::var("TERM").unwrap_or_default(); - direct_graphics_profile_values( - &term_program, - &term, - std::env::var_os("KITTY_WINDOW_ID").is_some(), - direct_attach - || is_remote_client_process() - || is_ssh_session() - || std::env::var_os("TMUX").is_some() - || std::env::var_os("STY").is_some(), - io::stdin().is_terminal() && io::stdout().is_terminal(), - ) -} - -#[cfg(not(unix))] -fn direct_graphics_profile_allowed(_direct_attach: bool) -> bool { - false -} - -fn requested_keybindings() -> ClientKeybindings { - match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) - .ok() - .as_deref() - { - Some("local") => crate::config::Config::load() - .config - .local_keybindings_profile_toml() - .map(|keys_toml| ClientKeybindings::Local { keys_toml }) - .unwrap_or(ClientKeybindings::Server), - _ => ClientKeybindings::Server, - } -} - -#[cfg(windows)] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option, - context: &'static str, -) -> Result<(), ClientError> { - match stream.set_recv_timeout(timeout) { - Ok(()) => Ok(()), - Err(err) if err.kind() == io::ErrorKind::Unsupported => { - debug!(err = %err, context, "client socket receive timeout unavailable"); - Ok(()) - } - Err(err) => Err(ClientError::ConnectionFailed(err)), - } -} - -#[cfg(not(windows))] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option, - _context: &'static str, -) -> Result<(), ClientError> { - stream - .set_recv_timeout(timeout) - .map_err(ClientError::ConnectionFailed) -} - -fn client_launch_mode( - direct_attach_requested: bool, - exact_cell_size: bool, - cell_width_px: u32, - cell_height_px: u32, -) -> ClientLaunchMode { - if direct_attach_requested { - ClientLaunchMode::TerminalAttach - } else if exact_cell_size - && cell_width_px > 0 - && cell_height_px > 0 - && direct_graphics_profile_allowed(false) - { - ClientLaunchMode::AppDirectGraphics - } else { - ClientLaunchMode::App - } -} - -/// Performs the client→server handshake. -/// -/// Sends Hello with the terminal size and protocol version, reads the Welcome -/// response. Returns Ok(()) on success, or an error if the server rejects us. -fn do_handshake( - stream: &mut LocalStream, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - exact_cell_size: bool, - requested_encoding: RenderEncoding, - direct_attach_requested: bool, -) -> Result { - stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; - - // Send Hello. - let hello = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - keybindings: requested_keybindings(), - launch_mode: client_launch_mode( - direct_attach_requested, - exact_cell_size, - cell_width_px, - cell_height_px, - ), - }; - protocol::write_message(stream, &hello) - .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; - - // Read Welcome. - set_handshake_recv_timeout( - stream, - Some(handshake_read_timeout()), - "client handshake read timeout unavailable", - )?; - let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; - set_handshake_recv_timeout( - stream, - None, - "failed to clear client handshake read timeout", - )?; - - match welcome { - ServerMessage::Welcome { - version, - encoding, - error, - } => { - if let Some(error) = error { - return Err(ClientError::HandshakeRejected { version, error }); - } - info!(version, ?encoding, "handshake succeeded"); - Ok(encoding) - } - _ => Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), - ))), + fn present_frame(&mut self, frame_data: FrameData) { + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; } } @@ -921,10 +300,10 @@ enum ClientLoopEvent { /// Structured input events from platforms without Unix-style stdin bytes. #[cfg(windows)] StdinEvents(Vec), - /// Terminal resize detected. - Resize(u16, u16, u32, u32), + /// Terminal resize detected, including current exact-pixel eligibility. + Resize(u16, u16, u32, u32, bool), /// Server message received. - ServerMessage(ServerMessage), + ServerMessage(Box), /// Server reader thread exited (connection lost). ServerDisconnected, /// Timer tick. @@ -936,19 +315,13 @@ enum ClientLoopEvent { /// /// This is the entry point called from `main.rs` when running in client mode. pub fn run_client() -> io::Result<()> { - run_client_with_mode( - requested_render_encoding(), - None, - None, - "connecting to server", - ) + run_client_with_mode(None, None, "connecting to server") } /// Runs a direct terminal attach client. #[cfg(unix)] pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { run_client_with_mode( - RenderEncoding::TerminalAnsi, Some((terminal_id, takeover)), Some(AttachEscapeState::default()), "attaching to terminal", @@ -965,266 +338,7 @@ pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result< )) } -/// Runs a read-only terminal session observer and prints one JSON envelope per frame. -pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { - let mut stream = - connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; - write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; - write_terminal_session_output(stream) -} - -/// Runs a writable terminal session controller. -pub fn run_terminal_session_control( - target: String, - takeover: bool, - cols: u16, - rows: u16, -) -> io::Result<()> { - let mut stream = connect_terminal_session_stream( - target.clone(), - cols, - rows, - "controlling terminal session", - )?; - write_to_server( - &mut stream, - &ClientMessage::ControlTerminal { target, takeover }, - )?; - - let mut write_stream = stream.try_clone()?; - let _input_thread = std::thread::spawn(move || { - let stdin = io::stdin(); - for line in stdin.lock().lines() { - let Ok(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - match terminal_control_command_from_json(&line) { - Ok(message) => { - let release = matches!(message, ClientMessage::Detach); - if write_to_server(&mut write_stream, &message).is_err() { - return; - } - if release { - return; - } - } - Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), - } - } - let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); - }); - - write_terminal_session_output(stream) -} - -fn connect_terminal_session_stream( - target: String, - cols: u16, - rows: u16, - log_message: &'static str, -) -> io::Result { - init_logging(); - - let socket_path = client_socket_path(); - crate::logging::startup("client"); - info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); - - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(stream) => stream, - Err(err) => { - eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); - std::process::exit(1); - } - }; - - match do_handshake( - &mut stream, - cols, - rows, - 0, - 0, - false, - RenderEncoding::TerminalAnsi, - true, - ) { - Ok(RenderEncoding::TerminalAnsi) => {} - Ok(encoding) => { - eprintln!( - "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" - ); - std::process::exit(1); - } - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); - } - } - - stream.set_nonblocking(false)?; - Ok(stream) -} - -fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { - let mut stdout = io::stdout().lock(); - loop { - match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { - Ok(ServerMessage::Terminal(frame)) => { - let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); - let line = serde_json::json!({ - "type": "terminal.frame", - "seq": frame.seq, - "encoding": "ansi", - "width": frame.width, - "height": frame.height, - "full": frame.full, - "bytes": encoded, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - } - Ok(ServerMessage::ServerShutdown { reason }) => { - let line = serde_json::json!({ - "type": "terminal.closed", - "reason": reason, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - return Ok(()); - } - Ok(ServerMessage::Graphics { .. }) => {} - Ok(_) => {} - Err(protocol::FramingError::UnexpectedEof) => return Ok(()), - Err(err) => return Err(io::Error::other(err.to_string())), - } - } -} - -#[derive(serde::Deserialize)] -#[serde(tag = "type")] -enum TerminalControlCommand { - #[serde(rename = "terminal.input")] - Input { - text: Option, - bytes: Option, - }, - #[serde(rename = "terminal.resize")] - Resize { - cols: u16, - rows: u16, - #[serde(default)] - cell_width_px: u32, - #[serde(default)] - cell_height_px: u32, - }, - #[serde(rename = "terminal.scroll")] - Scroll { - direction: TerminalControlScrollDirection, - lines: u16, - #[serde(default)] - source: TerminalControlScrollSource, - #[serde(default)] - column: Option, - #[serde(default)] - row: Option, - #[serde(default)] - modifiers: u8, - }, - #[serde(rename = "terminal.release")] - Release {}, -} - -#[derive(Clone, Copy, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Default, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollSource { - #[default] - Wheel, - PageKey, -} - -fn terminal_control_command_from_json(raw: &str) -> Result { - let command = serde_json::from_str::(raw) - .map_err(|err| format!("invalid json command: {err}"))?; - match command { - TerminalControlCommand::Input { text, bytes } => { - let data = match (text, bytes) { - (Some(_), Some(_)) => { - return Err("terminal.input accepts text or bytes, not both".into()) - } - (Some(text), None) => text.into_bytes(), - (None, Some(bytes)) => base64::engine::general_purpose::STANDARD - .decode(bytes) - .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, - (None, None) => Vec::new(), - }; - Ok(ClientMessage::Input { data }) - } - TerminalControlCommand::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } => { - if cols == 0 || rows == 0 { - return Err("terminal.resize cols and rows must be greater than 0".into()); - } - Ok(ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - }) - } - TerminalControlCommand::Scroll { - direction, - lines, - source, - column, - row, - modifiers, - } => { - if lines == 0 { - return Err("terminal.scroll lines must be greater than 0".into()); - } - let direction = match direction { - TerminalControlScrollDirection::Up => AttachScrollDirection::Up, - TerminalControlScrollDirection::Down => AttachScrollDirection::Down, - }; - let source = match source { - TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, - TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { - input: match direction { - AttachScrollDirection::Up => b"\x1b[5~".to_vec(), - AttachScrollDirection::Down => b"\x1b[6~".to_vec(), - }, - }, - }; - Ok(ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }) - } - TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), - } -} - fn run_client_with_mode( - requested_encoding: RenderEncoding, attach_request: Option<(String, bool)>, attach_escape: Option, log_message: &'static str, @@ -1233,25 +347,43 @@ fn run_client_with_mode( let loaded_config = crate::config::Config::load(); crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let client_rendered_shell = attach_request.is_none(); + let socket_path = client_socket_path(); + let keybinding_source = client_shell_keybinding_source(); + let startup_config_diagnostic = + if keybinding_source == shell::ClientShellKeybindingSource::Endpoint { + crate::config::config_diagnostic_summary_without_keybindings(&loaded_config.diagnostics) + } else { + crate::config::config_diagnostic_summary(&loaded_config.diagnostics) + }; + let shell_config = client_rendered_shell.then(|| { + shell::ClientShellConfig::from_config(&loaded_config.config) + .with_startup_config_diagnostic(startup_config_diagnostic) + .with_startup_onboarding(loaded_config.config.should_show_onboarding()) + .with_keybinding_source(keybinding_source) + .with_local_endpoint(&socket_path) + }); let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; let host_cursor = loaded_config.config.ui.host_cursor; - let direct_attach_requested = attach_request.is_some(); let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = - loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; + loaded_config.config.experimental.kitty_graphics && client_rendered_shell; + let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, host_cursor, kitty_graphics_enabled, + pixel_geometry_enabled, + pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, remote_image_paste_key, + shell_config, }; - let socket_path = client_socket_path(); crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); @@ -1268,7 +400,16 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) = - initial_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(pixel_geometry_enabled, kitty_graphics_enabled); + + let shell_surface_size = loop_config + .shell_config + .as_ref() + .map(|shell| shell.initial_surface_size(cols, rows)); + let endpoint_keybindings = loop_config + .shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( @@ -1278,8 +419,9 @@ fn run_client_with_mode( cell_width_px, cell_height_px, exact_cell_size, - requested_encoding, - direct_attach_requested, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, ) { Ok(encoding) => encoding, Err(err) => { @@ -1312,19 +454,11 @@ fn run_client_with_mode( err })?; - // Install a panic hook to restore the terminal on panic (same as monolithic). - let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys; - let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports; - #[cfg(windows)] - let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode; + // Install a panic hook so the foreground client always restores its terminal. + let panic_restore = terminal_guard.panic_restore(); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal_state( - panic_resets_modify_other_keys, - panic_resets_host_color_scheme_reports, - #[cfg(windows)] - panic_restore_windows_input_mode, - ); + panic_restore(); original_hook(info); })); @@ -1352,6 +486,7 @@ fn run_client_with_mode( rows, cell_width_px, cell_height_px, + exact_cell_size, should_quit, loop_config, negotiated_encoding, @@ -1388,6 +523,148 @@ fn run_client_with_mode( Ok(()) } +fn dispatch_client_shell_actions( + actions: Vec, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + write_stream: &mut LocalStream, + detached_process_children: &mut Vec, +) -> Result, ClientError> { + let mut replay_mouse = Vec::new(); + for action in actions { + match action { + shell::ClientShellAction::Endpoint { boot_id, request } => { + endpoint_commands.enqueue(boot_id, request); + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + endpoint_commands + .send_next(write_stream) + .map_err(ClientError::ConnectionLost)?; + Ok(replay_mouse) +} + +fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option, + write_stream: &mut LocalStream, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(write_stream, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let replay = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + write_stream, + &mut state.detached_process_children, + )?; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + for request in outcome.requests { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(false) +} + /// The main client event loop. /// /// Uses a threaded architecture: @@ -1401,6 +678,7 @@ async fn run_client_loop( rows: u16, initial_cell_width_px: u32, initial_cell_height_px: u32, + initial_pixel_geometry_exact: bool, should_quit: Arc, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1414,14 +692,25 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, + shell_mouse_capture_preference: config.mouse_capture_active, + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), + pane_keyboard_report_all: false, keyboard_report_all_active: false, reported_size: (cols, rows), + reported_cell_size: (initial_cell_width_px, initial_cell_height_px), sound_config: config.sound_config, kitty_graphics_enabled: config.kitty_graphics_enabled, + pixel_geometry_enabled: config.pixel_geometry_enabled, + pixel_geometry_exact: initial_pixel_geometry_exact, #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), attach_escape, #[cfg(unix)] mouse_scroll_lines: config.mouse_scroll_lines, @@ -1429,7 +718,12 @@ async fn run_client_loop( redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, draw_host_cursor, + detached_process_children: Vec::new(), + shell: config.shell_config.map(shell::ClientShellState::new), }; + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); + } debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. @@ -1440,6 +734,8 @@ async fn run_client_loop( // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(256); + let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); + // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); @@ -1475,6 +771,10 @@ async fn run_client_loop( if will_query_host_terminal_theme { query_host_terminal_theme(); + #[cfg(not(windows))] + if state.shell.is_some() { + query_host_terminal_appearance(); + } } if will_query_host_cell_size { @@ -1486,6 +786,8 @@ async fn run_client_loop( let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; + let pixel_geometry_enabled = state.pixel_geometry_enabled; + let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { resize_poll_loop( resize_tx, @@ -1493,7 +795,9 @@ async fn run_client_loop( rows, initial_cell_width_px, initial_cell_height_px, - kitty_graphics_enabled, + initial_pixel_geometry_exact, + pixel_geometry_enabled, + pixel_geometry_fallback, &resize_cell_size, &resize_quit, ); @@ -1527,19 +831,95 @@ async fn run_client_loop( // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). - use crate::platform::PrefixInputSource; let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); // Main event loop. + let mut client_timer = timer::ClientLoopTimer::new(); while !should_quit.load(Ordering::Acquire) { + let timer_delay = state + .shell + .as_ref() + .map_or(Duration::from_millis(100), |shell| { + shell.timer_delay(std::time::Instant::now()) + }); + let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); let event = tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), - _ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer, }; + let now = std::time::Instant::now(); + if let Some(shell) = state.shell.as_mut() { + shell.tick_popup_pending(now); + } match event { #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { + if state.shell.is_some() { + if will_query_host_cell_size { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) + { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } + } + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_paste( + &data, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_terminal_drop(&data, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_input_bytes(&data); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } let data = if let Some(attach_escape) = &mut state.attach_escape { match attach_escape.filter_input( data, @@ -1547,30 +927,33 @@ async fn run_client_loop( state.mouse_scroll_lines, ) { AttachInputAction::Forward(data) => data, - AttachInputAction::ForwardAfterPendingPrefix(data) => { - let prefix = ClientMessage::Input { data: vec![0x02] }; - if let Err(e) = write_to_server(&mut write_stream, &prefix) { + AttachInputAction::ForwardPair(first, second) => { + for data in [first, second] { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + } + continue; + } + AttachInputAction::Semantic(action) => { + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } - data + continue; } - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - modifiers, - } => { - let msg = ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + AttachInputAction::ForwardThenSemantic(prefix, action) => { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } continue; @@ -1606,7 +989,12 @@ async fn run_client_loop( state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "clipboard paste", + )?; continue; } info!( @@ -1614,7 +1002,12 @@ async fn run_client_loop( ); } if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "file drop", + )?; continue; } let msg = ClientMessage::Input { data }; @@ -1624,6 +1017,17 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { + let composed = state + .pending_surface_graphics + .remove(&response.transfer_id) + .filter(|_| response.success) + .and_then(|asset| { + let shell = state.shell.as_mut()?; + shell + .trust_direct_graphics_asset(&asset, response.image_id) + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten() + }); let message = ClientMessage::GraphicsTransmissionResult { transfer_id: response.transfer_id, image_id: response.image_id, @@ -1632,99 +1036,251 @@ async fn run_client_loop( if let Err(err) = write_to_server(&mut write_stream, &message) { return Err(ClientError::ConnectionLost(err)); } + if let Some(frame) = composed { + state.present_frame(frame); + } } #[cfg(unix)] ClientLoopEvent::PixelMouse(data, geometry) => { - let message = ClientMessage::InputPixels { - data, - cols: geometry.cols, - rows: geometry.rows, - width_px: geometry.width_px, - height_px: geometry.height_px, - }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if state.shell.is_some() { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_pixel_mouse(&data, geometry); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } + if let Some(attach_escape) = state.attach_escape.as_mut() { + if let Some(prefix) = attach_escape.take_pending_prefix() { + if let Err(err) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some((kind, position, modifiers)) = + direct_attach_pixel_mouse(&data, geometry) + { + let message = ClientMessage::AttachMouse { + kind, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: geometry.cols, + rows: geometry.rows, + width_px: geometry.width_px, + height_px: geometry.height_px, + }), + modifiers, + lines: state.mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + }; + if let Err(err) = write_to_server(&mut write_stream, &message) { + return Err(ClientError::ConnectionLost(err)); + } + } } } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { - if state.attach_escape.is_some() { - continue; - } - if should_bridge_clipboard_image_events( - &events, - is_remote_client, - state.remote_image_paste_key, - ) { - if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; - continue; + if state.shell.is_some() { + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_events( + &events, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_client_events(&events, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_client_events(&events); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); } - info!( - "clipboard image paste trigger received, but local clipboard has no image" - ); - } - if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; continue; } - let raw_events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect::>(); - if crate::raw_input::events_require_host_surface_redraw( - &raw_events, - state.redraw_on_focus_gained, - ) { - state.request_repaint(); - } - let msg = ClientMessage::InputEvents { events }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } - ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => { + ClientLoopEvent::Resize( + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) => { + if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { + set_mouse_capture(state.mouse_capture_active, false) + .map_err(ClientError::ConnectionFailed)?; + host_sgr_pixels_active.store(false, Ordering::Release); + } state.reported_size = (new_cols, new_rows); - // Resizing invalidates the host-side blit baseline. + state.reported_cell_size = (cell_width_px, cell_height_px); + state.pixel_geometry_exact = pixel_geometry_exact; + // Resizing invalidates both the host-side blit baseline and pane hit geometry. state.request_repaint(); - let msg = ClientMessage::Resize { - cols: new_cols, - rows: new_rows, - cell_width_px, - cell_height_px, + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(cell_width_px, cell_height_px); + shell.invalidate_pane_surface(); + } + let msg = if let Some(shell) = &state.shell { + client_shell_resize_message( + shell, + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) + } else { + ClientMessage::Resize { + cols: new_cols, + rows: new_rows, + cell_width_px, + cell_height_px, + pixel_mouse: pixel_geometry_exact, + } }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::ServerMessage(msg) => match msg { - ServerMessage::Frame(frame_data) => { - let frame_data = if state.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if state.draw_host_cursor { - state.blit_encoder.encode_with_suppressed_visible_cursor( - &frame_data, - state.repaint_pending, + ClientLoopEvent::ServerMessage(msg) => match *msg { + ServerMessage::ClientShellSnapshot(snapshot) => { + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell + { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.set_snapshot(snapshot); + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }), + graphics_cleanup, ) } else { - state - .blit_encoder - .encode(&frame_data, state.repaint_pending) + (None, None, Vec::new()) }; - let mut stdout = io::stdout(); - let graphics = if state.kitty_graphics_enabled { - frame_data.graphics.as_slice() + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + if let Err(err) = write_to_server(&mut write_stream, &resize) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurface(surface) => { + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); + shell.compose(state.reported_size.0, state.reported_size.1) } else { - &[] + None }; - let _ = - write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - state.blit_encoder.commit(frame_data, encoded); - state.repaint_pending = false; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since("client_surface_patch.apply", apply_started); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since("client_surface_patch.total", patch_started); + crate::render_prof::flush_if_due(); } ServerMessage::Terminal(frame) => { if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { @@ -1756,13 +1312,25 @@ async fn run_client_loop( transfer_id, leading, control, + surface_asset, } => { #[cfg(unix)] { if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { continue; } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; let valid = state.kitty_graphics_enabled + && surface_asset_valid && usize::try_from(expected_len).ok().is_some_and(|len| { crate::pane_graphics_files::validate_direct_source( std::path::Path::new(&path), @@ -1796,6 +1364,9 @@ async fn run_client_loop( false }; if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert(transfer_id, asset); + } if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.start(transfer_id); } @@ -1807,6 +1378,7 @@ async fn run_client_loop( return Err(ClientError::ConnectionLost(err)); } } else { + state.pending_surface_graphics.remove(&transfer_id); if let Ok(mut matcher) = state.direct_graphics_response.lock() { if valid { matcher.retire(transfer_id); @@ -1825,7 +1397,15 @@ async fn run_client_loop( } } #[cfg(not(unix))] - let _ = (path, expected_len, image_id, transfer_id, leading, control); + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } ServerMessage::GraphicsTransmissionRetired { transfer_id, @@ -1834,6 +1414,15 @@ async fn run_client_loop( #[cfg(unix)] { state.retired_direct_graphics = Some((transfer_id, image_id)); + state.pending_surface_graphics.remove(&transfer_id); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.retire(transfer_id); } @@ -1849,10 +1438,117 @@ async fn run_client_loop( message, body, } => { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = + shell.receive_notification(event, std::time::Instant::now()); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + } + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + let completed = endpoint_commands + .receive_chunk(&boot_id, &request_id, final_chunk, data) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; + }; + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + let replay_mouse = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + &mut state.detached_process_children, + )?; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } ServerMessage::Clipboard { data } => { - forward_clipboard(&data); + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } + } let _ = io::stdout().flush(); } ServerMessage::WindowTitle { title } => { @@ -1862,18 +1558,90 @@ async fn run_client_loop( ); } ServerMessage::ReloadSoundConfig => { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, &mut state.draw_host_cursor, &mut state.remote_image_paste_key, + &mut mouse_capture, ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = + state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + &mut write_stream, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels) + .map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(resize) = resize { + write_to_server(&mut write_stream, &resize) + .map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } } ServerMessage::MouseCapture { enabled, sgr_pixels, } => { - let next_sgr_pixels = enabled && sgr_pixels; + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = + effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); if mouse_mode_changed { @@ -1892,21 +1660,24 @@ async fn run_client_loop( host_mouse_capture_active.store(enabled, Ordering::Release); host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - ServerMessage::KittyKeyboardReportAll { enabled } => { - if enabled != state.keyboard_report_all_active { - crate::terminal_modes::set_host_kitty_keyboard_report_all( + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( &mut io::stdout(), - enabled, + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = enabled; } } - ServerMessage::PrefixInputSource { active } => { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; } } ServerMessage::Welcome { .. } => { @@ -1920,10 +1691,38 @@ async fn run_client_loop( ))); } ClientLoopEvent::Timer => { + client_timer.fired(); #[cfg(unix)] if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.expire(); } + state + .detached_process_children + .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + if state.shell.is_some() { + let (effects, outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, notification_repaint) = shell.tick_notifications(now); + let mut outcome = shell.tick_selection_autoscroll(now); + outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (effects, outcome, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } } } @@ -1965,7 +1764,7 @@ fn server_reader_thread( match protocol::read_message(&mut stream, max_frame_size) { Ok(msg) => { if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(msg)) + .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) .is_err() { break; // Main loop gone. @@ -2000,57 +1799,10 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result< protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) } -fn write_remote_image_to_server( - stream: &mut LocalStream, - image: crate::platform::ClipboardImage, - source: &'static str, -) -> Result<(), ClientError> { - if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { - warn!( - bytes = image.bytes.len(), - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - source, - "local image is too large to bridge" - ); - return Ok(()); - } - - info!( - bytes = image.bytes.len(), - extension = image.extension, - source, - "bridging local image to remote server" - ); - write_to_server( - stream, - &ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }, - ) - .map_err(ClientError::ConnectionLost) -} - // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- -fn client_remote_image_paste_key( - config: &crate::config::Config, -) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - - match config.remote_image_paste_key() { - Ok(key) => key, - Err(diagnostic) => { - warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); - None - } - } -} - fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, @@ -2059,17 +1811,29 @@ fn reload_local_client_config( crossterm::event::KeyCode, crossterm::event::KeyModifiers, )>, + mouse_capture: &mut bool, ) { match crate::config::load_live_config() { Ok(loaded) => { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); } - let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - *sound_config = loaded.config.ui.sound; - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *remote_image_paste_key = loaded_remote_image_paste_key; debug!("reloaded local client config"); } Err(diagnostics) => { @@ -2078,264 +1842,6 @@ fn reload_local_client_config( } } -fn handle_notify( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, -) { - handle_notify_with_notifiers( - kind, - message, - body, - sound_config, - crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, - ); -} - -fn handle_notify_with_notifiers( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, - mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result, - mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result, -) { - match kind { - NotifyKind::Sound => { - let Some(sound) = sound_from_notify_message(message) else { - warn!( - message = message, - "received unknown sound notification from server" - ); - return; - }; - if sound_config.enabled { - crate::sound::play(sound, sound_config); - } - } - NotifyKind::Toast => { - debug!( - message = message, - "received terminal toast notification from server" - ); - if let Err(err) = show_terminal_notification(message, body) { - warn!(err = %err, "failed to emit terminal notification"); - } - } - NotifyKind::SystemToast => { - debug!( - message = message, - "received system toast notification from server" - ); - if let Err(err) = show_system_notification(message, body) { - warn!(err = %err, "failed to emit system notification"); - } - } - } -} - -fn sound_from_notify_message(message: &str) -> Option { - match message { - "agent done" => Some(crate::sound::Sound::Done), - "agent attention" => Some(crate::sound::Sound::Request), - _ => None, - } -} - -#[cfg(unix)] -fn should_bridge_clipboard_image_paste( - data: &[u8], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - - let events = crate::raw_input::parse_raw_input_bytes_sync(data); - matches!( - events.as_slice(), - [crate::raw_input::RawInputEvent::Key(key)] - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) - ) -} - -#[cfg(any(windows, test))] -fn should_bridge_clipboard_image_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if !is_remote_client { - return false; - } - if matches!( - events, - [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty() - ) { - return true; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - matches!( - events, - [event] - if matches!( - event.to_raw_input_event(), - crate::raw_input::RawInputEvent::Key(key) - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo( - &key, - remote_image_paste_key, - ) - ) - ) -} - -#[cfg(unix)] -fn read_image_file_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option { - let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; - read_image_file(path, extension) -} - -#[cfg(any(windows, test))] -fn read_image_file_from_client_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, -) -> Option { - let [crate::protocol::ClientInputEvent::Paste { text }] = events else { - return None; - }; - let text = normalized_terminal_drop_text(text)?; - // Windows events already carry native paths; Unix backslash unescaping would corrupt them. - let (path, extension) = - image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; - read_image_file(path, extension) -} - -fn read_image_file( - path: std::path::PathBuf, - extension: &'static str, -) -> Option { - let metadata = std::fs::metadata(&path).ok()?; - if !metadata.is_file() { - return None; - } - - let file = std::fs::File::open(&path).ok()?; - let bytes = - match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { - crate::platform::LimitedRead::Complete(bytes) => bytes, - crate::platform::LimitedRead::Empty => return None, - crate::platform::LimitedRead::Oversized => { - warn!( - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - "local image file drop is too large to bridge" - ); - return None; - } - }; - - Some(crate::platform::ClipboardImage { bytes, extension }) -} - -#[cfg(unix)] -fn image_path_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - let bytes = bracketed_paste_payload(data).unwrap_or(data); - let text = std::str::from_utf8(bytes).ok()?; - let text = normalized_terminal_drop_text(text)?; - let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); - image_path_from_drop_text(&text, is_remote_client) -} - -fn normalized_terminal_drop_text(text: &str) -> Option<&str> { - let text = text.trim_end_matches(['\r', '\n']); - (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) -} - -fn image_path_from_drop_text( - text: &str, - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - if !is_remote_client { - return None; - } - let path = std::path::PathBuf::from(text); - if !path.is_absolute() { - return None; - } - let extension = recognized_image_extension(path.extension()?.to_str()?)?; - Some((path, extension)) -} - -#[cfg(unix)] -fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { - const START: &[u8] = b"\x1b[200~"; - const END: &[u8] = b"\x1b[201~"; - data.strip_prefix(START)?.strip_suffix(END) -} - -fn strip_matching_path_quotes(text: &str) -> &str { - if text.len() < 2 { - return text; - } - - let bytes = text.as_bytes(); - match (bytes.first(), bytes.last()) { - (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], - _ => text, - } -} - -#[cfg(unix)] -fn unescape_terminal_drop_path(text: &str) -> String { - let mut unescaped = String::with_capacity(text.len()); - let mut chars = text.chars(); - while let Some(ch) = chars.next() { - if ch == '\\' { - if let Some(escaped) = chars.next() { - unescaped.push(escaped); - } else { - unescaped.push(ch); - } - } else { - unescaped.push(ch); - } - } - unescaped -} - -fn recognized_image_extension(extension: &str) -> Option<&'static str> { - if extension.eq_ignore_ascii_case("png") { - Some("png") - } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { - Some("jpg") - } else if extension.eq_ignore_ascii_case("gif") { - Some("gif") - } else if extension.eq_ignore_ascii_case("webp") { - Some("webp") - } else if extension.eq_ignore_ascii_case("bmp") { - Some("bmp") - } else { - None - } -} - // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -2347,308 +1853,14 @@ fn decode_clipboard_payload(data: &str) -> Option> { } /// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) { +fn forward_clipboard(data: &str) -> bool { let Some(bytes) = decode_clipboard_payload(data) else { warn!("received invalid clipboard payload from server"); - return; + return false; }; crate::selection::write_osc52_bytes(&bytes); -} - -// --------------------------------------------------------------------------- -// Frame output -// --------------------------------------------------------------------------- - -fn write_encoded_frame_with_graphics( - mut writer: impl io::Write, - encoded: &[u8], - graphics: &[u8], -) -> io::Result<()> { - if graphics.is_empty() { - return writer.write_all(encoded); - } - - let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); - - writer.write_all(&encoded[..insertion])?; - record_received_kitty_graphics(graphics); - writer.write_all(b"\x1b7")?; - writer.write_all(graphics)?; - writer.write_all(b"\x1b8")?; - writer.write_all(&encoded[insertion..]) -} - -fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { - bytes.windows(3).any(|window| window == b"\x1b_G") -} - -fn record_received_kitty_graphics(bytes: &[u8]) { - let ids = kitty_graphics_image_ids(bytes); - if ids.is_empty() { - return; - } - let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); - if let Ok(mut set) = set.lock() { - set.extend(ids); - } -} - -fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { - let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { - return Ok(()); - }; - let Ok(mut set) = set.lock() else { - return Ok(()); - }; - for id in set.drain() { - write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; - } - writer.flush() -} - -fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec { - let mut ids = Vec::new(); - let mut index = 0usize; - while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { - let command_start = index + start + 3; - let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { - break; - }; - let command = &bytes[command_start..command_start + end]; - if let Some(id) = kitty_graphics_command_image_id(command) { - ids.push(id); - } - index = command_start + end + 2; - } - ids -} - -fn kitty_graphics_command_image_id(command: &[u8]) -> Option { - let header_end = command - .iter() - .position(|byte| *byte == b';') - .unwrap_or(command.len()); - for part in command[..header_end].split(|byte| *byte == b',') { - let Some(value) = part.strip_prefix(b"i=") else { - continue; - }; - let text = std::str::from_utf8(value).ok()?; - if let Ok(id) = text.parse::() { - return Some(id); - } - } - None -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - if needle.is_empty() || needle.len() > haystack.len() { - return None; - } - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -// --------------------------------------------------------------------------- -// Resize polling -// --------------------------------------------------------------------------- - -/// Cell size assumed when neither the terminal size ioctl nor the host -/// terminal reports pixel dimensions. -const DEFAULT_CELL_WIDTH_PX: u32 = 8; -const DEFAULT_CELL_HEIGHT_PX: u32 = 16; - -/// Cell size derived from the terminal size ioctl, if it reports pixels. -fn ioctl_cell_size() -> Option<(u32, u32)> { - let size = crossterm::terminal::window_size().ok()?; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some(( - (size.width as u32 / size.columns as u32).max(1), - (size.height as u32 / size.rows as u32).max(1), - )) -} - -/// Cell size used when the ioctl reports no pixels. -fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { - unpack_cell_size(reported) - .or(last.filter(|(width, height)| *width > 0 && *height > 0)) - .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) -} - -#[cfg(any(unix, test))] -fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { - (u64::from(width_px) << 32) | u64::from(height_px) -} - -fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { - let width_px = (packed >> 32) as u32; - let height_px = (packed & u64::from(u32::MAX)) as u32; - (width_px > 0 && height_px > 0).then_some((width_px, height_px)) -} - -fn current_terminal_geometry( - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - last_cell_size: Option<(u32, u32)>, -) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let (cell_width_px, cell_height_px) = ioctl_cell_size().unwrap_or_else(|| { - cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size) - }); - (cols, rows, cell_width_px, cell_height_px) -} - -/// Reads terminal geometry before the handshake. Direct graphics is eligible -/// only when the host supplied exact pixel dimensions through the ioctl. -fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32, bool) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0, false); - } - match ioctl_cell_size() { - Some((width, height)) => (cols, rows, width, height, true), - None => ( - cols, - rows, - DEFAULT_CELL_WIDTH_PX, - DEFAULT_CELL_HEIGHT_PX, - false, - ), - } -} - -/// Reports polled changes and signalled resizes that return to the same size. -fn resize_report_required( - signalled: bool, - new_size: (u16, u16, u32, u32), - last_size: (u16, u16, u32, u32), -) -> bool { - signalled || new_size != last_size -} - -/// Watches the terminal size and sends resize events when it changes. -/// -/// The baseline cell size must match what the handshake sent to the server: -/// reading a fresh one here would race the host cell size reply and could -/// swallow the first change. -fn resize_poll_loop( - resize_tx: tokio::sync::mpsc::Sender, - initial_cols: u16, - initial_rows: u16, - initial_cell_width: u32, - initial_cell_height: u32, - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - should_quit: &Arc, -) { - crate::platform::watch_terminal_resize_signal(); - let mut last_size = ( - initial_cols, - initial_rows, - initial_cell_width, - initial_cell_height, - ); - while !should_quit.load(Ordering::Acquire) { - std::thread::sleep(Duration::from_millis(100)); - let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry( - kitty_graphics_enabled, - reported_cell_size, - Some((last_size.2, last_size.3)), - ); - if resize_report_required(signalled, new_size, last_size) { - last_size = new_size; - if resize_tx - .blocking_send(ClientLoopEvent::Resize( - new_size.0, new_size.1, new_size.2, new_size.3, - )) - .is_err() - { - break; // Main loop gone. - } - } - } -} - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - -#[cfg(any(not(windows), test))] -fn query_host_terminal_appearance() { - let _ = write_host_terminal_appearance_query(io::stdout()); -} - -#[cfg(any(not(windows), test))] -fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; - writer.flush() -} - -/// Initialize logging for the client process. -fn query_host_terminal_theme() { - let _ = write_host_terminal_theme_query(io::stdout()); -} - -fn should_query_host_terminal_theme() -> bool { - !cfg!(windows) -} - -fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - writer.write_all(query.as_bytes())?; - writer.flush() -} - -/// XTWINOPS request for the host terminal cell size in pixels. -const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; - -fn query_host_cell_size() { - let _ = write_host_cell_size_query(io::stdout()); -} - -fn should_query_host_cell_size() -> bool { - !cfg!(windows) -} - -/// Only pane graphics need pixel dimensions, and only when the ioctl cannot -/// provide them. -fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { - kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() -} - -fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(HOST_CELL_SIZE_QUERY)?; - writer.flush() -} - -#[cfg(any(unix, test))] -fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { - let packed = pack_cell_size(width_px, height_px); - if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { - debug!(width_px, height_px, "host terminal reported cell size"); - } -} - -#[cfg(any(unix, test))] -fn reported_cell_size_from_events( - events: &[crate::raw_input::RawInputEvent], -) -> Option<(u32, u32)> { - events.iter().rev().find_map(|event| match event { - crate::raw_input::RawInputEvent::HostCellSizeReport { - width_px, - height_px, - } => Some((*width_px, *height_px)), - _ => None, - }) + true } fn init_logging() { @@ -2660,991 +1872,4 @@ fn init_logging() { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn resize_signal_reports_even_when_polled_size_is_unchanged() { - let size = (120, 40, 8, 16); - assert!(resize_report_required(true, size, size)); - assert!(!resize_report_required(false, size, size)); - assert!(resize_report_required(false, (120, 41, 8, 16), size)); - assert!(resize_report_required(false, (120, 40, 9, 18), size)); - } - - #[test] - fn approximate_cell_size_never_enables_direct_graphics() { - assert_eq!( - client_launch_mode(false, false, 8, 16), - ClientLaunchMode::App - ); - assert_eq!( - client_launch_mode(true, false, 8, 16), - ClientLaunchMode::TerminalAttach - ); - } - - #[test] - fn direct_graphics_profile_is_narrow_and_transport_safe() { - for (program, term, kitty, expected) in [ - ("ghostty", "", false, true), - ("WezTerm", "", false, true), - ("", "xterm-kitty", false, true), - ("", "xterm-256color", true, true), - ("", "xterm-256color", false, false), - ] { - assert_eq!( - direct_graphics_profile_values(program, term, kitty, false, true), - expected - ); - } - assert!(!direct_graphics_profile_values( - "ghostty", "", false, true, true - )); - assert!(!direct_graphics_profile_values( - "ghostty", "", false, false, false - )); - } - - fn restore_env_var(key: &str, value: Option) { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - restore_env_var(self.key, self.previous.clone()); - } - } - - #[test] - fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { - assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); - assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); - } - - struct EnvVarsRemovedGuard { - previous: Vec<(&'static str, Option)>, - } - - impl EnvVarsRemovedGuard { - fn new(keys: &[&'static str]) -> Self { - let previous: Vec<_> = keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(); - for key in keys { - std::env::remove_var(key); - } - Self { previous } - } - } - - impl Drop for EnvVarsRemovedGuard { - fn drop(&mut self) { - for (key, value) in self.previous.clone() { - restore_env_var(key, value); - } - } - } - - #[test] - fn remote_client_uses_extended_handshake_timeout() { - let _guard = env_lock().lock().unwrap(); - let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); - - assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); - } - - #[test] - fn host_cursor_policy_auto_uses_platform_default() { - assert_eq!( - should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), - crate::platform::should_draw_host_cursor_by_default() - ); - } - - #[test] - fn host_cursor_policy_native_and_drawn_override_auto_detection() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); - - assert!(!should_draw_host_cursor( - crate::config::HostCursorModeConfig::Native - )); - assert!(should_draw_host_cursor( - crate::config::HostCursorModeConfig::Drawn - )); - } - - #[cfg(unix)] - #[test] - fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - assert!(should_bridge_clipboard_image_paste( - &[0x16], - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[118;5u", - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - true, - None - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - false, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~text\x1b[201~", - true, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); - assert!(!should_bridge_clipboard_image_paste( - b"v", - true, - Some(ctrl_v) - )); - } - - struct TempImageFile { - path: std::path::PathBuf, - } - - impl TempImageFile { - fn new(extension: &str, bytes: &[u8]) -> Self { - Self::with_name_fragment("test", extension, bytes) - } - - fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", - std::process::id() - )); - std::fs::write(&path, bytes).unwrap(); - Self { path } - } - } - - impl Drop for TempImageFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } - } - - #[test] - fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - let key = crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('v'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - let empty_paste = crate::protocol::ClientInputEvent::Paste { - text: String::new(), - }; - - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&key), - true, - Some(ctrl_v), - )); - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&empty_paste), - true, - None, - )); - assert!(!should_bridge_clipboard_image_events( - &[key], - false, - Some(ctrl_v), - )); - assert!(!should_bridge_clipboard_image_events( - &[crate::protocol::ClientInputEvent::Paste { - text: "text".to_string(), - }], - true, - Some(ctrl_v), - )); - } - - #[test] - fn remote_image_file_drop_bridge_reads_semantic_paste_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let events = [crate::protocol::ClientInputEvent::Paste { - text: format!("\"{}\"", file.path.display()), - }]; - - let image = read_image_file_from_client_events(&events, true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { - let file = TempImageFile::new("jpeg", b"jpeg-bytes"); - let input = format!("'{}'\n", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "jpg"); - assert_eq!(image.bytes, b"jpeg-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { - let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); - let escaped_path = file.path.display().to_string().replace(' ', "\\ "); - - let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { - let file = TempImageFile::new("png", b"image-bytes"); - let path = file.path.display().to_string(); - - assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); - assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); - assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); - assert!(read_image_file_from_terminal_drop( - format!("{}\nextra", file.path.display()).as_bytes(), - true - ) - .is_none()); - } - - #[test] - fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics( - &mut output, - b"\x1b[?2026htext\x1b[?2026lcursor", - b"graphics", - ) - .unwrap(); - - assert_eq!( - output, - b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" - ); - } - - #[test] - fn empty_graphics_writes_only_blit_frame() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); - - assert_eq!(output, b"text"); - } - - #[test] - fn terminal_frame_kitty_detection_matches_apc_prefix() { - assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); - assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); - } - - #[test] - fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { - let ids = kitty_graphics_image_ids( - b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", - ); - assert_eq!(ids, vec![10023, 10023]); - } - - #[test] - fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { - record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); - let mut output = Vec::new(); - clear_received_kitty_graphics(&mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("a=d,d=I,i=123")); - assert!(!text.contains("d=A")); - } - - #[test] - fn write_host_terminal_appearance_query_emits_mode_2031_query() { - let mut output = Vec::new(); - write_host_terminal_appearance_query(&mut output).unwrap(); - assert_eq!(output, b"\x1b[?996n"); - } - - #[test] - fn write_host_terminal_theme_query_emits_osc_queries() { - let mut output = Vec::new(); - write_host_terminal_theme_query(&mut output).unwrap(); - assert_eq!( - output, - crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ) - .as_bytes() - ); - assert!(!output - .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) - .any(|window| window - == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())); - } - - #[test] - fn write_host_color_scheme_report_mode_emits_mode_sequences() { - let mut output = Vec::new(); - write_host_color_scheme_report_mode(&mut output, true).unwrap(); - write_host_color_scheme_report_mode(&mut output, false).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - assert_eq!(output, expected); - } - - #[test] - fn color_scheme_change_event_requests_host_theme_query() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - - assert!(crate::raw_input::events_require_host_terminal_theme_query( - &events - )); - } - - #[test] - fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); - } - - #[test] - fn write_host_cell_size_query_emits_xtwinops_request() { - let mut output = Vec::new(); - write_host_cell_size_query(&mut output).unwrap(); - - assert_eq!(output, b"\x1b[16t"); - } - - #[test] - fn host_cell_size_query_is_disabled_on_windows() { - assert_eq!(should_query_host_cell_size(), !cfg!(windows)); - } - - #[test] - fn cell_size_fallback_prefers_reported_then_previous_size() { - assert_eq!(cell_size_fallback(0, None), (8, 16)); - assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); - assert_eq!( - cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), - (10, 21) - ); - assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); - assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); - } - - #[test] - fn reported_cell_size_is_taken_from_host_cell_size_events() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - assert_eq!(reported_cell_size_from_events(&events), None); - - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); - assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); - } - - #[test] - fn color_scheme_reports_are_enabled_only_for_full_clients() { - assert_eq!( - should_enable_host_color_scheme_reports(true), - !cfg!(windows) - ); - assert!(!should_enable_host_color_scheme_reports(false)); - } - - #[test] - fn terminal_restore_postlude_restores_visible_default_cursor() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, false).unwrap(); - assert_eq!(output, b"\x1b[?25h\x1b[0 q"); - } - - #[test] - fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, true).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); - assert_eq!(output, expected); - } - - #[cfg(unix)] - #[test] - fn attach_escape_detaches_on_prefix_q() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Detach - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_sends_literal_prefix_on_double_prefix() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - match escape.filter_input(vec![0x02], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), - other => panic!("expected forwarded prefix, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_does_not_interpret_bracketed_paste_contents() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); - - match escape.filter_input(paste.clone(), 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), - other => panic!("expected opaque paste, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - - assert!(matches!( - escape.filter_input(paste.clone(), 24, 3), - AttachInputAction::ForwardAfterPendingPrefix(bytes) if bytes == paste - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"q" - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_prefix_before_non_escape_key() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![b'a', 0x02], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"a" - )); - match escape.filter_input(vec![b'x'], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), - other => panic!("expected forwarded bytes, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_wheel_into_scroll_action() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - .. - } => { - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 7); - assert_eq!(column, Some(10)); - assert_eq!(row, Some(5)); - } - other => panic!("expected scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_swallows_non_wheel_mouse_reports() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), - AttachInputAction::None - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_plain_page_keys_into_scroll_actions() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 11); - } - other => panic!("expected page-up scroll action, got {other:?}"), - } - - match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[6~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Down); - assert_eq!(lines, 11); - } - other => panic!("expected page-down scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_modified_page_key() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), - other => panic!("expected modified page key to forward, got {other:?}"), - } - } - - #[test] - fn client_error_display_connection_failed() { - let err = ClientError::ConnectionFailed(io::Error::new( - io::ErrorKind::ConnectionRefused, - "connection refused", - )); - let msg = err.to_string(); - assert!( - msg.contains("failed to connect to server"), - "should mention connection failure: {msg}" - ); - assert!( - msg.contains("herdr server"), - "should suggest starting server: {msg}" - ); - } - - #[test] - fn client_error_display_handshake_rejected() { - let err = ClientError::HandshakeRejected { - version: 1, - error: "incompatible".into(), - }; - let msg = err.to_string(); - assert!( - msg.contains("rejected handshake"), - "should mention rejection: {msg}" - ); - assert!(msg.contains("incompatible"), "should include error: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown() { - let err = ClientError::ServerShutdown { - reason: Some("maintenance".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - assert!(msg.contains("maintenance"), "should include reason: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown_no_reason() { - let err = ClientError::ServerShutdown { reason: None }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - } - - #[test] - fn client_error_display_detached_default_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[ - crate::remote::REATTACH_COMMAND_ENV_VAR, - crate::session::SESSION_ENV_VAR, - ]); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr` to reattach"), - "should suggest default reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_named_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr session attach work` to reattach"), - "should suggest named session reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_remote_reattach_hint_takes_precedence() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should prefer remote reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_connection_lost() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to server"), - "should mention lost connection: {msg}" - ); - } - - #[test] - fn client_error_display_remote_connection_lost_has_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to remote Herdr"), - "should mention remote connection loss: {msg}" - ); - assert!( - msg.contains("panes may still be running"), - "should explain possible persistence: {msg}" - ); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should show remote reattach command: {msg}" - ); - } - - #[test] - fn sound_from_notify_message_maps_done() { - assert_eq!( - sound_from_notify_message("agent done"), - Some(crate::sound::Sound::Done) - ); - } - - #[test] - fn sound_from_notify_message_maps_attention() { - assert_eq!( - sound_from_notify_message("agent attention"), - Some(crate::sound::Sound::Request) - ); - } - - #[test] - fn sound_from_notify_message_rejects_unknown_payloads() { - assert_eq!(sound_from_notify_message("toast"), None); - } - - #[test] - fn reload_local_client_config_refreshes_local_client_presentation_state() { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - let path = std::env::temp_dir().join(format!( - "herdr-client-config-reload-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write( - &path, - "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\n", - ) - .unwrap(); - let path_string = path.to_string_lossy().to_string(); - let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); - let mut sound_config = crate::config::SoundConfig::default(); - let mut redraw_on_focus_gained = true; - let mut draw_host_cursor = false; - let mut remote_image_paste_key = None; - - reload_local_client_config( - &mut sound_config, - &mut redraw_on_focus_gained, - &mut draw_host_cursor, - &mut remote_image_paste_key, - ); - - assert!(!redraw_on_focus_gained); - assert!(draw_host_cursor); - let _ = std::fs::remove_file(path); - } - - #[test] - fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::Toast, - "pi finished", - Some("workspace 1"), - &sound_config, - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - |_, _| Ok(false), - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_from_server_uses_system_notifier() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "pi finished", - Some("workspace 1"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_preserves_colon_in_title() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "build: failed", - Some("api workspace"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(( - "build: failed".to_string(), - Some("api workspace".to_string()) - )) - ); - } - - #[test] - fn decode_clipboard_payload_decodes_base64() { - assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); - } - - #[test] - fn decode_clipboard_payload_rejects_invalid_base64() { - assert_eq!(decode_clipboard_payload("not-base64!!!"), None); - } - - #[test] - fn terminal_control_input_command_accepts_text() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"hello"); - } - - #[test] - fn terminal_control_input_command_accepts_base64_bytes() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"\x1b[A"); - } - - #[test] - fn terminal_control_resize_command_maps_to_client_resize() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, - ) - .unwrap(); - let ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } = action - else { - panic!("expected resize command"); - }; - assert_eq!( - (cols, rows, cell_width_px, cell_height_px), - (100, 30, 8, 16) - ); - } - - #[test] - fn terminal_control_scroll_command_maps_to_attach_scroll() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, - ) - .unwrap(); - let ClientMessage::AttachScroll { - source, - direction, - lines, - .. - } = action - else { - panic!("expected scroll command"); - }; - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 3); - } - - #[test] - fn forward_clipboard_uses_local_clipboard_path() { - unsafe { - std::env::set_var("SSH_CONNECTION", "1 2 3 4"); - } - forward_clipboard("dGVzdA=="); - unsafe { - std::env::remove_var("SSH_CONNECTION"); - } - } -} +mod tests; diff --git a/src/client/notifications.rs b/src/client/notifications.rs new file mode 100644 index 00000000..7c97742e --- /dev/null +++ b/src/client/notifications.rs @@ -0,0 +1,102 @@ +use std::io; + +use tracing::{debug, warn}; + +use crate::protocol::NotifyKind; + +use super::shell; + +pub(super) fn handle_shell_notification_effects( + effects: Vec, + sound_config: &crate::config::SoundConfig, +) { + for effect in effects { + match effect { + shell::ClientShellNotificationEffect::Sound { sound, agent } => { + let agent = agent.as_deref().and_then(crate::detect::parse_agent_label); + if sound_config.allows(agent) { + crate::sound::play(sound, sound_config); + } + } + shell::ClientShellNotificationEffect::Terminal { title, body } => { + if let Err(err) = crate::terminal_notify::show_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit terminal notification"); + } + } + shell::ClientShellNotificationEffect::System { title, body } => { + if let Err(err) = + crate::platform::show_desktop_notification(&title, body.as_deref()) + { + warn!(err = %err, "failed to emit system notification"); + } + } + } + } +} + +pub(super) fn handle_notify( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, +) { + handle_notify_with_notifiers( + kind, + message, + body, + sound_config, + crate::terminal_notify::show_notification, + crate::platform::show_desktop_notification, + ); +} + +pub(super) fn handle_notify_with_notifiers( + kind: NotifyKind, + message: &str, + body: Option<&str>, + sound_config: &crate::config::SoundConfig, + mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result, + mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result, +) { + match kind { + NotifyKind::Sound => { + let Some(sound) = sound_from_notify_message(message) else { + warn!( + message = message, + "received unknown sound notification from server" + ); + return; + }; + if sound_config.enabled { + crate::sound::play(sound, sound_config); + } + } + NotifyKind::Toast => { + debug!( + message = message, + "received terminal toast notification from server" + ); + if let Err(err) = show_terminal_notification(message, body) { + warn!(err = %err, "failed to emit terminal notification"); + } + } + NotifyKind::SystemToast => { + debug!( + message = message, + "received system toast notification from server" + ); + if let Err(err) = show_system_notification(message, body) { + warn!(err = %err, "failed to emit system notification"); + } + } + } +} + +pub(super) fn sound_from_notify_message(message: &str) -> Option { + match message { + "agent done" => Some(crate::sound::Sound::Done), + "agent attention" => Some(crate::sound::Sound::Request), + _ => None, + } +} diff --git a/src/client/shell.rs b/src/client/shell.rs new file mode 100644 index 00000000..9a877744 --- /dev/null +++ b/src/client/shell.rs @@ -0,0 +1,287 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +mod actions; +mod agent_sidebar; +mod composition; +mod config; +mod context_menu; +mod copy_mode; +mod global_menu; +mod graphics; +mod input; +mod mobile; +mod mouse; +mod notifications; +mod overlay_input; +mod preferences; +mod render; +mod scroll; +mod settings; +mod state; +mod surface_patch; +mod worktrees; + +pub(crate) use state::*; +#[cfg(test)] +pub(super) use surface_patch::apply_composed_surface_patch; +pub(super) use surface_patch::{ClientComposedSurfacePatch, ClientPaneSurfacePatchOutcome}; + +use crossterm::event::KeyCode; +#[cfg(test)] +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use unicode_width::UnicodeWidthStr; + +use crate::app::state::Palette; +use crate::config::{ + Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, + TabBarPositionConfig, +}; +use crate::protocol::{ + ClientMessage, ClientMousePosition, ClientPaneInputEvent, ClientShellSnapshot, ClientShellTab, + ClientShellWorkspace, ClientSurfaceSize, FrameData, PaneSurfaceFrame, SemanticNotification, + SemanticNotificationKind, SemanticNotificationSound, +}; +#[cfg(test)] +use crate::raw_input::RawInputEvent; + +fn delete_overlay_word(rename: &mut ClientRenameOverlay) { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + return; + } + while rename.input.chars().last().is_some_and(char::is_whitespace) { + rename.input.pop(); + } + let Some(word) = rename + .input + .chars() + .last() + .map(|character| character.is_alphanumeric() || character == '_') + else { + return; + }; + while rename.input.chars().last().is_some_and(|character| { + !character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word + }) { + rename.input.pop(); + } +} + +fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage { + match target { + ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput { + pane_id, + events: vec![event], + }, + ClientInputTarget::Popup(terminal_id) => ClientMessage::ClientShellPopupInput { + terminal_id, + events: vec![event], + }, + } +} + +fn push_target_event( + target: ClientInputTarget, + event: ClientPaneInputEvent, + outcome: &mut ClientShellInput, +) { + match target { + ClientInputTarget::Pane(pane_id) => { + if let Some(ClientMessage::ClientShellPaneInput { + pane_id: pending_pane, + events, + }) = outcome.requests.last_mut() + { + if *pending_pane == pane_id { + events.push(event); + return; + } + } + outcome.requests.push(target_event_message( + ClientInputTarget::Pane(pane_id), + event, + )); + } + ClientInputTarget::Popup(terminal_id) => { + if let Some(ClientMessage::ClientShellPopupInput { + terminal_id: pending_terminal, + events, + }) = outcome.requests.last_mut() + { + if *pending_terminal == terminal_id { + events.push(event); + return; + } + } + outcome.requests.push(target_event_message( + ClientInputTarget::Popup(terminal_id), + event, + )); + } + } +} + +fn contains(rect: Rect, point: (u16, u16)) -> bool { + rect.width > 0 + && rect.height > 0 + && point.0 >= rect.x + && point.0 < rect.right() + && point.1 >= rect.y + && point.1 < rect.bottom() +} + +fn pane_surface_topology_signature(surface: &PaneSurfaceFrame) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + fn write(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(PRIME); + } + *hash ^= 0xff; + *hash = hash.wrapping_mul(PRIME); + } + + let mut pane_ids = surface + .panes + .iter() + .map(|pane| pane.pane_id.as_bytes()) + .collect::>(); + pane_ids.sort_unstable(); + let mut hash = OFFSET; + for pane_id in pane_ids { + write(&mut hash, pane_id); + } + let mut splits = surface.splits.iter().collect::>(); + splits.sort_by(|left, right| left.path.cmp(&right.path)); + for split in splits { + write( + &mut hash, + &[match split.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => 0, + crate::protocol::PaneSurfaceSplitDirection::Vertical => 1, + }], + ); + write( + &mut hash, + &split + .path + .iter() + .map(|right| u8::from(*right)) + .collect::>(), + ); + } + hash +} + +fn status_icon( + status: crate::api::schema::AgentStatus, + style: crate::config::StatusIndicatorStyle, +) -> &'static str { + use crate::api::schema::AgentStatus; + use crate::config::StatusIndicatorStyle; + match (style, status) { + ( + StatusIndicatorStyle::Dots, + AgentStatus::Working | AgentStatus::Blocked | AgentStatus::Done, + ) => "●", + (StatusIndicatorStyle::Dots, AgentStatus::Idle) => "○", + (StatusIndicatorStyle::Dots, AgentStatus::Unknown) => "·", + (StatusIndicatorStyle::Symbols, AgentStatus::Blocked) => "×", + (StatusIndicatorStyle::Symbols, AgentStatus::Working) => "◐", + (StatusIndicatorStyle::Symbols, AgentStatus::Done) => "✓", + (StatusIndicatorStyle::Symbols, AgentStatus::Idle) => "○", + (StatusIndicatorStyle::Symbols, AgentStatus::Unknown) => "·", + } +} + +fn status_dot(status: crate::api::schema::AgentStatus) -> &'static str { + status_icon(status, crate::config::StatusIndicatorStyle::Dots) +} + +fn status_priority(status: crate::api::schema::AgentStatus) -> u8 { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => 4, + AgentStatus::Done => 3, + AgentStatus::Working => 2, + AgentStatus::Idle => 1, + AgentStatus::Unknown => 0, + } +} + +fn status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Working => "working", + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Idle => "idle", + AgentStatus::Unknown => "unknown", + } +} + +fn status_color( + status: crate::api::schema::AgentStatus, + palette: &Palette, +) -> ratatui::style::Color { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Working => palette.yellow, + AgentStatus::Blocked => palette.red, + AgentStatus::Done => palette.teal, + AgentStatus::Idle => palette.green, + AgentStatus::Unknown => palette.overlay0, + } +} + +fn panel_contrast_fg(palette: &Palette) -> ratatui::style::Color { + match palette.panel_bg { + ratatui::style::Color::Reset => palette.surface_dim, + color => color, + } +} + +fn blit_pane_surface(target: &mut FrameData, source: &FrameData, area: Rect) { + let copy_width = source.width.min(area.width); + let copy_height = source.height.min(area.height); + let hyperlink_base = target.hyperlinks.len() as u32; + target.hyperlinks.extend(source.hyperlinks.iter().cloned()); + + for row in 0..copy_height { + for col in 0..copy_width { + let source_index = row as usize * source.width as usize + col as usize; + let target_x = area.x + col; + let target_y = area.y + row; + let target_index = target_y as usize * target.width as usize + target_x as usize; + let (Some(source_cell), Some(target_cell)) = ( + source.cells.get(source_index), + target.cells.get_mut(target_index), + ) else { + continue; + }; + *target_cell = source_cell.clone(); + target_cell.hyperlink = source_cell.hyperlink.and_then(|index| { + ((index as usize) < source.hyperlinks.len()).then_some(hyperlink_base + index) + }); + } + } + + target.cursor = source.cursor.as_ref().and_then(|cursor| { + (cursor.x < copy_width && cursor.y < copy_height).then(|| crate::protocol::CursorState { + x: area.x + cursor.x, + y: area.y + cursor.y, + visible: cursor.visible, + shape: cursor.shape, + }) + }); + target.graphics.clear(); +} + +#[cfg(test)] +mod tests; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs new file mode 100644 index 00000000..efc9aae9 --- /dev/null +++ b/src/client/shell/actions.rs @@ -0,0 +1,1051 @@ +use super::*; + +impl ClientShellState { + pub(super) fn record_binding( + &mut self, + binding: crate::input::KeybindMatch, + outcome: &mut ClientShellInput, + ) { + match binding { + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Detach) => { + outcome.detach = true; + } + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ToggleSidebar) => { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } + crate::input::KeybindMatch::Action(action) => { + if matches!( + action, + crate::input::KeybindAction::NewWorktree + | crate::input::KeybindAction::OpenWorktree + | crate::input::KeybindAction::RemoveWorktree + ) { + self.begin_worktree_action(action, outcome); + return; + } + if action == crate::input::KeybindAction::OpenNavigator { + self.open_navigator_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Help { + self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Settings { + self.open_settings_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::OpenNotificationTarget { + self.focus_visible_notification(outcome); + return; + } + if action == crate::input::KeybindAction::ReloadConfig { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + self.reload_client_config(); + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + return; + } + if action == crate::input::KeybindAction::NewWorkspace { + if self.config.prompt_new_workspace_name { + self.open_new_workspace_overlay(); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: self.workspace_action_id(), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameWorkspace { + self.open_rename_workspace_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CloseWorkspace { + if let Some(workspace_id) = self.workspace_action_id() { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name + { + self.open_new_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameTab { + self.open_rename_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenamePane { + self.open_rename_pane_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::WorkspacePicker { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::EnterResizeMode { + self.mode = ClientShellMode::Resize; + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CopyMode { + if self.enter_copy_mode(outcome) { + outcome.repaint = true; + } + return; + } + if let Some(method) = self.endpoint_method_for_action(action) { + self.push_endpoint_method(method, outcome); + return; + } + outcome.actions.push(ClientShellAction::Keybind(action)); + } + crate::input::KeybindMatch::Command(command) => { + let action = command.action.into(); + let resolved_labels = command.bindings.labels(); + let command_id = self.snapshot.as_deref().and_then(|snapshot| { + if let Some(candidate) = snapshot.commands.iter().find(|candidate| { + candidate.command_id == command.command && candidate.action == action + }) { + return Some(candidate.command_id.clone()); + } + let mut candidates = snapshot.commands.iter().filter(|candidate| { + candidate.action == action + && !resolved_labels.is_empty() + && resolved_labels + .iter() + .all(|label| candidate.binding_labels.contains(label)) + }); + let candidate = candidates.next()?; + candidates + .next() + .is_none() + .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(), + ); + outcome.repaint = true; + return; + }; + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let selection = (action == crate::protocol::ClientShellCommandAction::PluginAction) + .then(|| { + let selection = self.selection.as_ref()?; + if !selection.is_visible() { + return None; + } + if snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return None; + } + let content_revision = self + .pane_surface + .as_ref()? + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id)? + .content_revision; + let (anchor, cursor) = selection.ordered_cells(); + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: selection.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision: Some(content_revision), + }) + }) + .flatten(); + let params = crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + pane_id: snapshot.focused_pane_id.clone(), + selection, + }; + if action == crate::protocol::ClientShellCommandAction::Popup { + self.popup_pending = true; + self.popup_pending_deadline = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::CommandInvoke(params), + PendingEndpointKind::PopupCommand, + outcome, + ); + } else { + self.push_endpoint_method( + crate::api::schema::Method::CommandInvoke(params), + outcome, + ); + } + } + } + } + + pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { + self.request_selection_copy_with_fallback(outcome, None); + } + + pub(super) fn request_selection_copy_with_fallback( + &mut self, + outcome: &mut ClientShellInput, + fallback_key: Option, + ) { + let Some(selection) = self.selection.as_ref() else { + return; + }; + let pane_id = selection.pane_id.clone(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map(|pane| pane.content_revision); + let (anchor, cursor) = selection.ordered_cells(); + let fallback = fallback_key.and_then(|key| { + let press = ClientPaneInputEvent::from_terminal_key(key.clone())?; + let tracks_release = matches!( + &press, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + ); + let mut message = + super::target_event_message(ClientInputTarget::Pane(pane_id.clone()), press); + if tracks_release { + let release = ClientPaneInputEvent::from_terminal_key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )?; + if let ClientMessage::ClientShellPaneInput { events, .. } = &mut message { + events.push(release); + } + } + Some(message) + }); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision, + }, + ), + PendingEndpointKind::SelectionCopy { fallback }, + outcome, + ); + } + + pub(super) fn request_word_selection( + &mut self, + hit: &PaneHit, + viewport_row: u16, + col: u16, + outcome: &mut ClientShellInput, + ) { + let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.word_selection_generation = self.word_selection_generation.saturating_add(1); + let generation = self.word_selection_generation; + self.pending_word_selection = Some(generation); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id: hit.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: 0, + }, + cursor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: hit.inner_rect.width.saturating_sub(1), + }, + content_revision, + }, + ), + PendingEndpointKind::WordSelection { + pane_id: hit.pane_id.clone(), + absolute_row, + col, + generation, + }, + outcome, + ); + } + + pub(super) fn push_endpoint_method( + &mut self, + method: crate::api::schema::Method, + outcome: &mut ClientShellInput, + ) { + self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome); + } + + pub(super) fn push_endpoint_method_with_kind( + &mut self, + method: crate::api::schema::Method, + kind: PendingEndpointKind, + outcome: &mut ClientShellInput, + ) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let confirmation_workspace_id = match &method { + crate::api::schema::Method::TabClose(target) => snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == target.tab_id) + .map(|tab| tab.workspace_id.clone()), + crate::api::schema::Method::PaneClose(target) => snapshot + .panes + .iter() + .find(|pane| pane.pane_id == target.pane_id) + .map(|pane| pane.workspace_id.clone()), + _ => None, + }; + let request_id = self.next_request_id; + self.next_request_id = self.next_request_id.saturating_add(1); + let request_id = format!("client-shell:{request_id}"); + self.pending_requests.insert( + request_id.clone(), + PendingEndpointRequest { + boot_id: snapshot.boot_id.clone(), + confirmation_workspace_id, + kind, + }, + ); + outcome.actions.push(ClientShellAction::Endpoint { + boot_id: snapshot.boot_id.clone(), + request: Box::new(crate::api::schema::Request { + id: request_id, + method, + }), + }); + } + + pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool { + if self.endpoint_error.as_deref() == Some(message.as_str()) { + return false; + } + self.endpoint_error = Some(message); + true + } + + pub(crate) fn handle_endpoint_result( + &mut self, + boot_id: &str, + request_id: &str, + result: Result, + ) -> (bool, Vec) { + let Some(pending) = self.pending_requests.remove(request_id) else { + return (false, Vec::new()); + }; + if pending.boot_id != boot_id + || self + .snapshot + .as_deref() + .is_none_or(|snapshot| snapshot.boot_id != boot_id) + { + return (false, Vec::new()); + } + match pending.kind { + PendingEndpointKind::Generic => {} + PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + let key = (version.clone(), id.clone()); + if self.dismissed_product_announcement.as_ref() == Some(&key) { + self.dismissed_product_announcement = None; + } + if self.overlay.is_none() { + if let Some(announcement) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.product_announcement.as_ref()) + .filter(|announcement| { + announcement.version == version && announcement.id == id + }) + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::ReleaseNotesDismiss => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + if self.overlay.is_none() { + if let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + { + self.overlay = Some(ClientShellOverlay::ReleaseNotes( + release_notes_state(notes), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PopupCommand => { + return match result { + Ok(_) => { + self.popup_pending_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); + (false, Vec::new()) + } + Err(error) => { + self.popup_pending = false; + self.popup_pending_deadline = None; + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PaneScroll { pane_id, serial } => { + let mut outcome = ClientShellInput::default(); + let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome); + return (repaint, outcome.actions); + } + PendingEndpointKind::SelectionCopy { fallback } => { + let fallback = || { + fallback + .map(ClientShellAction::Request) + .into_iter() + .collect::>() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. }) + if !text.is_empty() => + { + let repaint = self.show_copy_feedback(std::time::Instant::now()); + ( + repaint, + vec![ClientShellAction::ClipboardWrite(text.into_bytes())], + ) + } + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + (false, fallback()) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected selection result".to_owned()); + (true, fallback()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, fallback()) + } + }; + } + PendingEndpointKind::WordSelection { + pane_id, + absolute_row, + col, + generation, + } => { + if self.pending_word_selection != Some(generation) + || self.snapshot.as_deref().is_none_or(|snapshot| { + !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id) + }) + { + return (false, Vec::new()); + } + self.pending_word_selection = None; + let row_text = match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: returned_pane_id, + text, + }) if returned_pane_id == pane_id => text, + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + return (false, Vec::new()) + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected word-selection result".to_owned(), + ); + return (true, Vec::new()); + } + Err(error) => { + self.endpoint_error = Some(error.message); + return (true, Vec::new()); + } + }; + let Some((start_col, end_col)) = + crate::app::actions::word_bounds_at_column(&row_text, col) + else { + self.selection = None; + return (true, Vec::new()); + }; + let mut selection = crate::selection::Selection::absolute_range( + pane_id, + (absolute_row, start_col), + (absolute_row, end_col), + ); + if !selection.finish() { + return (false, Vec::new()); + } + self.selection = Some(selection); + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + if !self.config.copy_on_select { + return (true, Vec::new()); + } + self.selection_highlight_clear_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + let mut outcome = ClientShellInput::default(); + self.request_selection_copy(&mut outcome); + return (true, outcome.actions); + } + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events, + } => { + let completed_before_release = !fallback_events.iter().any(|event| { + event.kind + == crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) + }); + let replay = (self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane_id && hit.inner_rect == inner_rect)) + .then_some(fallback_events); + if replay.is_none() { + self.url_click_consumes_until_up = completed_before_release; + } + let replay_action = |events: Option>| { + events + .map(ClientShellAction::ReplayMouse) + .into_iter() + .collect() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + handled: true, + .. + }) => { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some(url), + handled: false, + }) if crate::app::actions::safe_web_url(&url).is_some() => { + self.url_click_consumes_until_up = completed_before_release; + (false, vec![ClientShellAction::OpenSafeWebUrl(url)]) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { .. }) => { + (false, replay_action(replay)) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected link result".to_owned()); + (true, replay_action(replay)) + } + Err(error) + if matches!( + error.code.as_deref(), + Some("stale_content" | "stale_target") + ) => + { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, replay_action(replay)) + } + }; + } + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: returned_pane_id, + cursor, + content_revision, + }) if returned_pane_id == pane_id => ( + self.apply_copy_motion_target( + &pane_id, + origin, + cursor, + content_revision, + &mut outcome, + ), + true, + ), + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { .. }) => (false, false), + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected copy-motion result".to_owned()); + (true, false) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: returned_pane_id, + content_revision, + matches, + total, + current, + current_global, + }) if returned_pane_id == pane_id => { + let repaint = self.apply_copy_search_result( + &pane_id, + origin, + query, + direction, + repeat, + generation, + ClientCopySearchResult { + content_revision, + matches, + total, + current: current.and_then(|index| usize::try_from(index).ok()), + current_global, + }, + &mut outcome, + ); + if !repaint { + self.cancel_deferred_copy_after_search(generation); + } + (repaint, repaint) + } + Ok(crate::api::schema::ResponseResult::PaneCopySearch { .. }) => { + self.cancel_deferred_copy_after_search(generation); + (false, false) + } + Ok(_) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = + Some("endpoint returned an unexpected copy-search result".to_owned()); + (true, false) + } + Err(error) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::ReloadConfig => { + 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()); + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + return (repaint, Vec::new()); + } + kind @ (PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall) => { + return self.handle_settings_endpoint_result(kind, result); + } + kind => { + return ( + self.handle_worktree_endpoint_result(kind, result), + Vec::new(), + ); + } + } + let repaint = match result { + Ok(_) => false, + Err(error) + if self.config.confirm_close + && error.code.as_deref() == Some("confirmation_required") + && pending.confirmation_workspace_id.is_some() => + { + if let Some(workspace_id) = pending.confirmation_workspace_id { + self.open_confirm_close_overlay(workspace_id); + } + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + (repaint, Vec::new()) + } + + pub(super) fn endpoint_method_for_action( + &mut self, + action: crate::input::KeybindAction, + ) -> Option { + use crate::api::schema::{ + Method, PaneDirection, PaneFocusDirectionParams, PaneResizeParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + TabCreateParams, TabMoveParams, TabTarget, WorkspaceTarget, + }; + use crate::input::KeybindAction; + + let snapshot = self.snapshot.as_deref()?; + let focused_workspace = snapshot.focused_workspace_id.clone()?; + let focused_tab = snapshot.focused_tab_id.clone(); + let focused_pane = snapshot.focused_pane_id.clone(); + let direction = |action| match action { + KeybindAction::FocusPaneLeft + | KeybindAction::SwapPaneLeft + | KeybindAction::ResizePaneLeft => Some(PaneDirection::Left), + KeybindAction::FocusPaneDown + | KeybindAction::SwapPaneDown + | KeybindAction::ResizePaneDown => Some(PaneDirection::Down), + KeybindAction::FocusPaneUp + | KeybindAction::SwapPaneUp + | KeybindAction::ResizePaneUp => Some(PaneDirection::Up), + KeybindAction::FocusPaneRight + | KeybindAction::SwapPaneRight + | KeybindAction::ResizePaneRight => Some(PaneDirection::Right), + _ => None, + }; + + match action { + KeybindAction::FocusAgent(index) => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + Some(Method::PaneFocus(PaneTarget { + pane_id: agents.get(index)?.clone(), + })) + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return None; + } + let current = agents.iter().position(|pane_id| { + Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref() + }); + let next = match (current, action) { + (Some(current), KeybindAction::PreviousAgent) => { + (current + agents.len() - 1) % agents.len() + } + (Some(current), KeybindAction::NextAgent) => (current + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + (None, KeybindAction::NextAgent) => 0, + _ => unreachable!("relative agent action"), + }; + let pane_id = agents[next].clone(); + if !self + .hits + .agents + .iter() + .any(|(_, visible_pane_id)| visible_pane_id == &pane_id) + { + self.agent_scroll = next.min(self.hits.agent_max_scroll); + } + Some(Method::PaneFocus(PaneTarget { pane_id })) + } + KeybindAction::SwitchWorkspace(index) => { + let entries = self.navigation_workspace_entries(snapshot); + let workspace_id = snapshot + .workspaces + .get(entries.get(index)?.index)? + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => { + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return None; + } + let current = entries + .iter() + .position(|entry| { + snapshot.workspaces[entry.index].workspace_id == focused_workspace + }) + .unwrap_or(0); + let delta = if action == KeybindAction::PreviousWorkspace { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::SwitchTab(index) => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::>(); + Some(Method::TabFocus(TabTarget { + tab_id: tabs.get(index)?.tab_id.clone(), + })) + } + KeybindAction::PreviousTab | KeybindAction::NextTab => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::>(); + let focused_tab = focused_tab?; + let current = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let delta = if action == KeybindAction::PreviousTab { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(tabs.len() as isize) as usize; + Some(Method::TabFocus(TabTarget { + tab_id: tabs[next].tab_id.clone(), + })) + } + KeybindAction::MoveTabPrevious | KeybindAction::MoveTabNext => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::>(); + if tabs.len() <= 1 { + return None; + } + let focused_tab = focused_tab?; + let source = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let insert_index = if action == KeybindAction::MoveTabNext { + if source + 1 >= tabs.len() { + 0 + } else { + source + 2 + } + } else if source == 0 { + tabs.len() + } else { + source - 1 + }; + Some(Method::TabMove(TabMoveParams { + tab_id: focused_tab, + insert_index, + })) + } + KeybindAction::NewTab if !self.config.prompt_new_tab_name => { + Some(Method::TabCreate(TabCreateParams { + workspace_id: Some(focused_workspace), + cwd: None, + focus: true, + label: None, + env: Default::default(), + })) + } + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight => { + Some(Method::PaneFocusDirection(PaneFocusDirectionParams { + pane_id: focused_pane, + direction: direction(action)?, + })) + } + KeybindAction::SwapPaneLeft + | KeybindAction::SwapPaneDown + | KeybindAction::SwapPaneUp + | KeybindAction::SwapPaneRight => Some(Method::PaneSwap(PaneSwapParams { + pane_id: focused_pane, + direction: Some(direction(action)?), + source_pane_id: None, + target_pane_id: None, + })), + KeybindAction::SplitVertical | KeybindAction::SplitHorizontal => { + Some(Method::PaneSplit(PaneSplitParams { + workspace_id: Some(focused_workspace), + target_pane_id: focused_pane, + direction: if action == KeybindAction::SplitVertical { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + })) + } + KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { + tab_id: focused_tab?, + })), + KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { + pane_id: focused_pane.clone()?, + })), + KeybindAction::CyclePaneNext | KeybindAction::CyclePanePrevious => { + let focused_tab = focused_tab?; + let panes = snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == focused_tab) + .collect::>(); + if panes.is_empty() { + return None; + } + let focused_pane = focused_pane?; + let current = panes + .iter() + .position(|pane| pane.pane_id == focused_pane) + .unwrap_or(0); + let next = if action == KeybindAction::CyclePanePrevious { + (current + panes.len() - 1) % panes.len() + } else { + (current + 1) % panes.len() + }; + Some(Method::PaneFocus(PaneTarget { + pane_id: panes[next].pane_id.clone(), + })) + } + KeybindAction::LastPane => { + let pane_id = self.previous_pane_id.as_ref()?; + if Some(pane_id.as_str()) == focused_pane.as_deref() + || !snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id) + { + return None; + } + Some(Method::PaneFocus(PaneTarget { + pane_id: pane_id.clone(), + })) + } + KeybindAction::Zoom => Some(Method::PaneZoom(PaneZoomParams { + pane_id: focused_pane, + mode: PaneZoomMode::Toggle, + })), + KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { + pane_id: focused_pane?, + })), + KeybindAction::ResizePaneLeft + | KeybindAction::ResizePaneDown + | KeybindAction::ResizePaneUp + | KeybindAction::ResizePaneRight => Some(Method::PaneResize(PaneResizeParams { + pane_id: focused_pane, + direction: direction(action)?, + amount: None, + })), + _ => None, + } + } +} diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs new file mode 100644 index 00000000..8f87b6ad --- /dev/null +++ b/src/client/shell/agent_sidebar.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::Line, + widgets::{Paragraph, Widget}, +}; + +use super::*; + +struct AgentRow { + pane_id: String, + status: crate::api::schema::AgentStatus, + focused: bool, + rows: Vec>, +} + +pub(super) fn ordered_agent_pane_ids( + snapshot: &ClientShellSnapshot, + sort: crate::config::AgentPanelSortConfig, +) -> Vec { + if snapshot.agent_view_label.is_some() { + return snapshot + .agent_order + .iter() + .filter(|pane_id| { + snapshot + .agents + .iter() + .any(|agent| agent.pane_id == pane_id.as_str()) + }) + .cloned() + .collect(); + } + let mut agents = snapshot.agents.iter().collect::>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + agents.sort_by_key(|agent| { + ( + std::cmp::Reverse(status_priority(agent.agent_status)), + std::cmp::Reverse(agent.state_change_seq), + ) + }); + } + agents + .into_iter() + .map(|agent| agent.pane_id.clone()) + .collect() +} + +pub(super) fn render_agent_panel( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if area.height == 0 { + return; + } + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(area.width as usize), + Style::default().fg(config.palette.surface_dim), + ); + if area.height < 2 { + return; + } + put_text( + buffer, + area.x, + area.y + 1, + area.width, + " agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + let sort_label = + snapshot + .agent_view_label + .as_deref() + .unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); + let sort_width = display_width(sort_label).min(area.width as usize) as u16; + let sort_rect = Rect::new( + area.right().saturating_sub(sort_width), + area.y + 1, + sort_width, + 1, + ); + hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + sort_rect + } else { + Rect::default() + }; + put_text( + buffer, + sort_rect.x, + sort_rect.y, + sort_rect.width, + sort_label, + Style::default() + .fg(if snapshot.agent_view_label.is_some() { + config.palette.accent + } else { + config.palette.overlay0 + }) + .add_modifier(Modifier::BOLD), + ); + + let rows = agent_rows(snapshot, config); + let body = Rect::new( + area.x, + area.y.saturating_add(3), + area.width, + area.height.saturating_sub(3), + ); + hits.agent_body = body; + if body.is_empty() || rows.is_empty() { + *agent_scroll = 0; + if !body.is_empty() && snapshot.agent_view_label.is_some() { + put_text( + buffer, + body.x, + body.y, + body.width, + " no matching agents", + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + return; + } + + let row_heights = rows + .iter() + .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .collect::>(); + let gaps = rows + .iter() + .enumerate() + .map(|(index, _)| { + if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + } + }) + .collect::>(); + let metrics = + super::scroll::list_scroll_metrics(&row_heights, &gaps, body.height, *agent_scroll); + hits.agent_max_scroll = metrics.max_offset_from_bottom; + hits.agent_scroll_metrics = Some(metrics); + *agent_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { + let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + y = y + .saturating_add(height) + .saturating_add(if index + 1 < rows.len() { + config.agents.row_gap + } else { + 0 + }); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.agent_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, &config.palette); + } +} + +fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> 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 { + 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, + }) + }) + .collect() +} + +fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { + let palette = &config.palette; + let row_style = if row.focused { + Style::default().bg(palette.active_row_bg) + } else { + Style::default() + }; + let name_style = if row.focused { + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .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 icon = ( + status_icon(row.status, config.status_indicators), + Style::default().fg(status_color(row.status, palette)), + ); + let rows = if row.rows.is_empty() { + vec![vec![crate::ui::ResolvedToken { + kind: crate::ui::ResolvedTokenKind::StateIcon, + style: Default::default(), + }]] + } else { + row.rows.clone() + }; + for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { + let indent = if index == 0 { 1 } else { 3 }; + let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; + spans.extend(crate::ui::resolved_token_spans( + tokens, + icon, + status_style, + name_style, + secondary, + secondary, + palette, + rect.width.saturating_sub(indent as u16) as usize, + )); + Paragraph::new(Line::from(spans)).style(row_style).render( + Rect::new(rect.x, rect.y + index as u16, rect.width, 1), + buffer, + ); + } +} + +fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + for (offset, character) in text.chars().take(width as usize).enumerate() { + if let Some(cell) = buffer.cell_mut((x + offset as u16, y)) { + cell.set_char(character).set_style(style); + } + } +} + +fn display_width(text: &str) -> usize { + unicode_width::UnicodeWidthStr::width(text) +} + +fn sidebar_status_text(status: crate::api::schema::AgentStatus) -> &'static str { + use crate::api::schema::AgentStatus; + match status { + AgentStatus::Blocked => "blocked", + AgentStatus::Done => "done", + AgentStatus::Working => "working", + AgentStatus::Idle | AgentStatus::Unknown => "idle", + } +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs new file mode 100644 index 00000000..6432f3e1 --- /dev/null +++ b/src/client/shell/composition.rs @@ -0,0 +1,539 @@ +use super::*; + +fn restore_mode_bar( + frame: &mut FrameData, + bar: Option, + cells: Option<&[crate::protocol::CellData]>, +) { + let (Some(bar), Some(cells)) = (bar, cells) else { + return; + }; + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].clone_from_slice(cells); + if frame + .cursor + .as_ref() + .is_some_and(|cursor| cursor.y == bar.y) + { + frame.cursor = None; + } +} + +impl ClientShellState { + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option { + self.last_composed_size = Some((cols, rows)); + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + let layout = self.layout(cols, rows); + if self.last_tab_bar_width != Some(layout.tab_bar.width) { + self.last_tab_bar_width = Some(layout.tab_bar.width); + self.reveal_focused_tab = true; + } + let tab_drag_insert_index = match &self.chrome_drag { + Some(ClientChromeDrag::Tab { insert_index, .. }) => *insert_index, + _ => None, + }; + let (dragged_workspace_id, workspace_drop_indicator_row) = match &self.chrome_drag { + Some(ClientChromeDrag::Workspace { + source_workspace_id, + target, + }) => ( + Some(source_workspace_id.as_str()), + target.as_ref().map(|(_, row)| *row), + ), + _ => (None, None), + }; + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + self.hits = render::render_shell( + &mut buffer, + layout, + snapshot, + &self.config, + render::ShellRenderState { + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: self.sidebar_collapsed, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index, + selected_workspace_id: (self.mode == ClientShellMode::Navigate) + .then_some(self.navigate_workspace_id.as_deref()) + .flatten(), + dragged_workspace_id, + workspace_drop_indicator_row, + }, + ); + self.hits.panes = surface + .panes + .iter() + .map(|pane| PaneHit { + rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.rect.x), + layout.pane_surface.y.saturating_add(pane.rect.y), + pane.rect.width, + pane.rect.height, + ), + inner_rect: Rect::new( + layout.pane_surface.x.saturating_add(pane.inner_rect.x), + layout.pane_surface.y.saturating_add(pane.inner_rect.y), + pane.inner_rect.width, + pane.inner_rect.height, + ), + scrollbar_rect: pane.scrollbar_rect.map(|rect| { + Rect::new( + layout.pane_surface.x.saturating_add(rect.x), + layout.pane_surface.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }), + scroll: pane.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }), + pane_id: pane.pane_id.clone(), + popup: false, + mouse_reporting: pane.mouse_reporting, + sgr_pixel_mouse: pane.sgr_pixel_mouse, + pixel_width: pane.pixel_width, + pixel_height: pane.pixel_height, + }) + .collect(); + let topology_signature = pane_surface_topology_signature(surface); + self.hits.pane_splits = surface + .splits + .iter() + .map(|split| PaneSplitHit { + direction: split.direction, + pos: match split.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + layout.pane_surface.x.saturating_add(split.pos) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + layout.pane_surface.y.saturating_add(split.pos) + } + }, + area: Rect::new( + layout.pane_surface.x.saturating_add(split.area.x), + layout.pane_surface.y.saturating_add(split.area.y), + split.area.width, + split.area.height, + ), + hit_rect: Rect::new( + layout.pane_surface.x.saturating_add(split.hit_rect.x), + layout.pane_surface.y.saturating_add(split.hit_rect.y), + split.hit_rect.width, + split.hit_rect.height, + ), + path: split.path.clone(), + topology_signature, + }) + .collect(); + if !self.config.mouse_capture { + self.hits.pane_splits.clear(); + } + let mode_bar_area = if layout.mobile_header.is_empty() + && self.config.tab_bar_position == TabBarPositionConfig::Bottom + && !layout.tab_bar.is_empty() + { + layout.tab_bar + } else { + layout.pane_surface + }; + let mobile_navigate_panel = !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.endpoint_error.is_none(); + let mode_bar = if mobile_navigate_panel || self.overlay.is_some() { + None + } else { + render::render_mode_bar( + &mut buffer, + mode_bar_area, + self.mode, + self.copy_mode.as_ref(), + self.endpoint_error.as_deref(), + snapshot.update_available.is_some(), + &self.config.keybinds, + &self.config.palette, + ) + }; + if mode_bar == Some(layout.tab_bar) { + self.hits.tabs.clear(); + self.hits.new_tab = Rect::default(); + self.hits.tab_scroll_left = Rect::default(); + self.hits.tab_scroll_right = Rect::default(); + } + let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]); + let mode_bar_cells = mode_bar.map(|bar| { + let start = usize::from(bar.y) * usize::from(frame.width) + usize::from(bar.x); + frame.cells[start..start + usize::from(bar.width)].to_vec() + }); + blit_pane_surface(&mut frame, &surface.frame, layout.pane_surface); + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + let has_selection = self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let has_search = self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| !copy_mode.search_matches.is_empty()); + if has_selection || has_search { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + for hit in &self.hits.panes { + let copy_surface_coherent = + client_copy_surface_coherent(self.copy_mode.as_ref(), hit); + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + false, + ); + } + let selection_is_stale_copy_projection = !copy_surface_coherent + && self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.pane_id == hit.pane_id + && self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == hit.pane_id) + }); + if !selection_is_stale_copy_projection { + crate::ui::render_selection_highlight( + self.selection.as_ref(), + &mut composed, + &hit.pane_id, + hit.inner_rect, + hit.scroll, + &self.config.palette, + crate::terminal_theme::TerminalTheme::default(), + ); + } + if copy_surface_coherent { + render_client_copy_search_highlights( + &mut composed, + self.copy_mode.as_ref(), + hit, + &self.config.palette, + true, + ); + } + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if self.mode == ClientShellMode::Copy { + frame.cursor = None; + if let Some(copy_mode) = self.copy_mode.as_ref() { + if let Some(hit) = self.hits.panes.iter().find(|hit| { + hit.pane_id == copy_mode.pane_id + && client_copy_surface_coherent(Some(copy_mode), hit) + }) { + let viewport_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top); + if viewport_row < u32::from(hit.inner_rect.height) + && copy_mode.cursor.col < hit.inner_rect.width + { + 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; + composed[(x, y)].set_style( + Style::default() + .fg(match self.config.palette.panel_bg { + ratatui::style::Color::Reset => self.config.palette.surface_dim, + color => color, + }) + .bg(self.config.palette.accent) + .add_modifier(Modifier::BOLD), + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + } else { + frame.cursor = None; + } + } + } + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + self.hits.notification_toast = Rect::default(); + let has_config_diagnostic = self.config_diagnostic.is_some(); + if has_config_diagnostic || self.visible_notification.is_some() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + if let Some(diagnostic) = self.config_diagnostic.as_deref() { + let diagnostic_area = if layout.mobile_header.is_empty() { + Rect::new(0, 0, cols, rows) + } else { + layout.pane_surface + }; + crate::ui::render_config_diagnostic_buffer( + &mut composed, + diagnostic_area, + diagnostic, + &self.config.palette, + ); + } + if let Some(notification) = self.visible_notification.as_ref() { + self.hits.notification_toast = if layout.mobile_header.is_empty() { + notifications::render_visible_notification( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + self.config.toast_position, + u16::from(has_config_diagnostic), + &self.config.palette, + ) + } else { + notifications::render_mobile_notification_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notification, + has_config_diagnostic, + &self.config.palette, + ) + }; + } + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(feedback) = self.copy_feedback.as_ref() { + let cursor = frame.cursor.clone(); + let mut composed = frame.to_ratatui_buffer()?; + let base_offset = u16::from(has_config_diagnostic); + let feedback_area = if layout.mobile_header.is_empty() { + layout.pane_surface + } else { + Rect::new(0, 0, cols, rows) + }; + let offset = crate::ui::copy_feedback_offset_for_toast( + feedback_area, + feedback, + base_offset, + self.config.clipboard_toast_position, + self.hits.notification_toast, + ); + 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; + if let Some(popup) = surface.popup.as_deref() { + let width = popup.width.map(client_popup_size); + let height = popup.height.map(client_popup_size); + if let Some(geometry) = + crate::popup_size::resolve_popup_geometry(width, height, layout.pane_surface) + { + let mut composed = frame.to_ratatui_buffer()?; + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(ratatui::style::Style::default().fg(self.config.palette.accent)) + .title(popup.title.clone()) + .style(ratatui::style::Style::default().bg(self.config.palette.panel_bg)); + ratatui::widgets::Widget::render( + ratatui::widgets::Clear, + geometry.outer, + &mut composed, + ); + ratatui::widgets::Widget::render(block, geometry.outer, &mut composed); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + blit_pane_surface(&mut frame, &popup.frame, geometry.inner); + self.hits.popup = Some(PaneHit { + rect: geometry.outer, + inner_rect: geometry.inner, + scrollbar_rect: None, + scroll: None, + pane_id: popup.terminal_id.clone(), + popup: true, + mouse_reporting: popup.mouse_reporting, + sgr_pixel_mouse: popup.sgr_pixel_mouse, + pixel_width: popup.pixel_width, + pixel_height: popup.pixel_height, + }); + } + } + if !layout.mobile_header.is_empty() + && self.mode == ClientShellMode::Navigate + && self.overlay.is_none() + { + let mut composed = frame.to_ratatui_buffer()?; + super::mobile::render_mobile_switcher( + &mut composed, + Rect::new(0, 0, cols, rows), + snapshot, + &self.config, + self.navigate_workspace_id.as_deref(), + &mut self.mobile_switcher_scroll, + &mut self.reveal_mobile_workspace, + &mut self.hits, + ); + frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } + restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); + 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 = + render::render_context_menu(&mut composed, menu, &self.config.palette)?; + None + } else if let ClientShellOverlay::GlobalMenu(menu) = overlay { + self.hits.global_menu_rows = render::render_global_menu( + &mut composed, + self.hits.global_launcher, + menu, + snapshot, + &self.config.palette, + )?; + None + } else { + let rendered = render::render_client_overlay( + &mut composed, + overlay, + snapshot, + &self.config.keybinds, + &self.config.palette, + )?; + self.hits.overlay_primary = rendered.primary; + self.hits.overlay_clear = rendered.clear; + self.hits.overlay_cancel = rendered.cancel; + self.hits.navigator_popup = rendered.navigator_popup; + self.hits.navigator_search = rendered.navigator_search; + self.hits.navigator_rows = rendered.navigator_rows; + self.hits.worktree_search = rendered.worktree_search; + self.hits.worktree_rows = rendered.worktree_rows; + self.hits.help_popup = rendered.help_popup; + self.hits.help_scrollbar = rendered.help_scrollbar; + self.hits.help_scroll_metrics = rendered.help_scroll_metrics; + self.hits.help_max_scroll = rendered.help_max_scroll; + self.hits.settings_popup = rendered.settings_popup; + self.hits.settings_tabs = rendered.settings_tabs; + self.hits.settings_choices = rendered.settings_choices; + self.hits.product_announcement_scrollbar = rendered.product_announcement_scrollbar; + self.hits.product_announcement_scroll_metrics = + rendered.product_announcement_scroll_metrics; + self.hits.product_announcement_max_scroll = + rendered.product_announcement_max_scroll; + self.hits.release_notes_scrollbar = rendered.release_notes_scrollbar; + self.hits.release_notes_scroll_metrics = rendered.release_notes_scroll_metrics; + self.hits.release_notes_max_scroll = rendered.release_notes_max_scroll; + rendered.cursor + }; + frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); + } + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help.scroll.min(self.hits.help_max_scroll); + } + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = announcement + .scroll + .min(u16::try_from(self.hits.product_announcement_max_scroll).unwrap_or(u16::MAX)); + } + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = notes + .scroll + .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); + } + self.compose_graphics(&mut frame, layout); + Some(frame) + } +} + +fn client_copy_surface_coherent(copy_mode: Option<&ClientCopyModeState>, hit: &PaneHit) -> bool { + copy_mode + .filter(|copy_mode| copy_mode.pane_id == hit.pane_id) + .is_none_or(|copy_mode| { + copy_mode.geometry == (hit.inner_rect.width, hit.inner_rect.height) + && hit.scroll.is_some_and(|scroll| { + scroll.offset_from_bottom == copy_mode.offset_from_bottom + && scroll.max_offset_from_bottom == copy_mode.max_offset_from_bottom + }) + }) +} + +fn render_client_copy_search_highlights( + buffer: &mut Buffer, + copy_mode: Option<&ClientCopyModeState>, + hit: &PaneHit, + palette: &Palette, + current_only: bool, +) { + let Some(copy_mode) = copy_mode.filter(|copy_mode| copy_mode.pane_id == hit.pane_id) else { + return; + }; + if hit.inner_rect.is_empty() { + return; + } + let top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let bottom = top.saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))); + let style = if current_only { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.surface1) + }; + for (index, text_match) in copy_mode.search_matches.iter().enumerate() { + if (copy_mode.search_current == Some(index)) != current_only + || text_match.end.row < top + || text_match.start.row > bottom + { + continue; + } + let start_row = text_match.start.row.max(top); + let end_row = text_match.end.row.min(bottom); + for absolute_row in start_row..=end_row { + let viewport_row = absolute_row.saturating_sub(top) as u16; + let start_col = if absolute_row == text_match.start.row { + text_match.start.col + } else { + 0 + }; + let end_col = if absolute_row == text_match.end.row { + text_match.end.col + } else { + hit.inner_rect.width.saturating_sub(1) + }; + for col in start_col..=end_col.min(hit.inner_rect.width.saturating_sub(1)) { + buffer[( + hit.inner_rect.x.saturating_add(col), + hit.inner_rect.y.saturating_add(viewport_row), + )] + .set_style(style); + } + } + } +} + +fn client_popup_size(size: crate::protocol::ClientShellPopupSize) -> crate::popup_size::PopupSize { + match size { + crate::protocol::ClientShellPopupSize::Cells(cells) => { + crate::popup_size::PopupSize::Cells(cells) + } + crate::protocol::ClientShellPopupSize::Percent(percent) => { + crate::popup_size::PopupSize::Percent(percent) + } + } +} diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs new file mode 100644 index 00000000..0273cefb --- /dev/null +++ b/src/client/shell/config.rs @@ -0,0 +1,503 @@ +use super::*; + +pub(super) fn merged_config_diagnostic( + local: Option<&str>, + endpoint: Option<&str>, +) -> Option { + match (local, endpoint) { + (Some(local), Some(endpoint)) if local == endpoint => { + Some(format!("client + endpoint: {local}")) + } + (Some(local), Some(endpoint)) => Some(format!("client: {local}\nendpoint: {endpoint}")), + (Some(local), None) => Some(local.to_owned()), + (None, Some(endpoint)) => Some(endpoint.to_owned()), + (None, None) => None, + } +} + +impl ClientShellState { + pub(super) fn set_local_config_diagnostic(&mut self, diagnostic: Option) { + self.local_config_diagnostic = diagnostic; + self.config_diagnostic = merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.config_diagnostic.as_deref()), + ); + } + + pub(super) fn persist_chrome_preferences(&mut self, outcome: &mut ClientShellInput) { + let Some(path) = self.config.preferences_path.as_deref() else { + return; + }; + let mut collapsed_groups = self.collapsed_groups.iter().cloned().collect::>(); + collapsed_groups.sort(); + let preferences = preferences::ClientChromePreferences { + sidebar_width: self.sidebar_width_manual.then_some(self.sidebar_width), + sidebar_section_split: self + .sidebar_section_split_manual + .then_some(self.sidebar_section_split), + sidebar_collapsed: self + .sidebar_collapsed_manual + .then_some(self.sidebar_collapsed), + agent_panel_sort: self + .agent_panel_sort_manual + .then_some(self.config.agent_panel_sort), + collapsed_groups, + }; + if let Err(error) = preferences::store(path, preferences) { + self.endpoint_error = Some(error); + outcome.repaint = true; + } + } + + pub(crate) fn reload_client_config(&mut self) { + match crate::config::load_live_config() { + Ok(loaded) => { + let agent_panel_sort = self.config.agent_panel_sort; + let diagnostics = self.config.apply_live_config( + &loaded.config, + &loaded.diagnostics, + &loaded.invalid_sections, + ); + if let Some(appearance) = self.host_appearance { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + } + if !self.sidebar_width_manual { + self.sidebar_width = self.config.sidebar_width; + } + if self.agent_panel_sort_manual { + self.config.agent_panel_sort = agent_panel_sort; + } + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + if let Some(snapshot) = self.snapshot.as_deref() { + let profile = snapshot.server_keybindings_toml.clone(); + let commands = snapshot.commands.clone(); + if let Err(err) = self + .config + .apply_snapshot_keybindings(profile.as_deref(), &commands) + { + self.endpoint_error = Some(err); + } + } + } + Err(diagnostics) => { + self.set_local_config_diagnostic(self.config.local_config_diagnostic(&diagnostics)); + } + } + self.reconcile_input_source(); + } +} + +impl ClientShellConfig { + pub(crate) fn from_config(config: &Config) -> Self { + let theme_runtime = crate::app::client_theme_runtime_from_config(config); + Self { + sidebar_width: config.ui.sidebar_width, + sidebar_min_width: config.ui.sidebar_min_width, + sidebar_max_width: config.ui.sidebar_max_width, + sidebar_start_collapsed: config.ui.sidebar_start_collapsed, + sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, + mobile_width_threshold: config.ui.mobile_width_threshold, + tab_bar_position: config.ui.tab_bar_position, + hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, + spaces: config.ui.sidebar.spaces.clone(), + agents: config.ui.sidebar.agents.clone(), + agent_panel_sort: config.ui.agent_panel_sort, + status_indicators: config.ui.status_indicators, + sound_enabled: config.ui.sound.enabled, + toast_delivery: config.ui.toast.delivery, + toast_delay_seconds: config.ui.toast.delay_seconds, + toast_position: config.ui.toast.herdr.position, + copy_on_select: config.ui.copy_on_select, + clipboard_toast_enabled: config.ui.toast.clipboard.enabled, + clipboard_toast_position: config.ui.toast.clipboard.position, + theme_name: theme_runtime.manual_name.clone(), + theme_runtime, + palette: crate::app::client_palette_from_config(config), + keybinds: config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .unwrap_or_else(|_diagnostics| LiveKeybindConfig { + prefix: config.prefix_key(), + keybinds: config.keybinds(), + }), + local_keys: config.keys.clone(), + keybinding_source: ClientShellKeybindingSource::Local, + prompt_new_tab_name: config.ui.prompt_new_tab_name, + prompt_new_workspace_name: config.ui.prompt_new_workspace_name, + confirm_close: config.ui.confirm_close, + mouse_capture: config.ui.mouse_capture, + mouse_scroll_lines: config.ui.mouse_scroll_lines(), + right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), + redraw_on_focus_gained: config.ui.redraw_on_focus_gained, + switch_ascii_input_source_in_prefix: config + .experimental + .switch_ascii_input_source_in_prefix, + local_config_path: crate::config::config_path(), + preferences_path: None, + preferences: preferences::ClientChromePreferences::default(), + startup_config_diagnostic: None, + startup_onboarding: false, + } + } + + pub(crate) fn with_startup_config_diagnostic(mut self, diagnostic: Option) -> Self { + self.startup_config_diagnostic = diagnostic; + self + } + + pub(crate) fn with_startup_onboarding(mut self, show: bool) -> Self { + self.startup_onboarding = show; + self + } + + pub(crate) fn with_keybinding_source(mut self, source: ClientShellKeybindingSource) -> Self { + self.keybinding_source = source; + self.keybinds.keybinds.custom_commands.clear(); + self + } + + pub(crate) fn uses_endpoint_keybindings(&self) -> bool { + self.keybinding_source == ClientShellKeybindingSource::Endpoint + } + + pub(super) fn local_config_diagnostic(&self, diagnostics: &[String]) -> Option { + if self.uses_endpoint_keybindings() { + crate::config::config_diagnostic_summary_without_keybindings(diagnostics) + } else { + crate::config::config_diagnostic_summary(diagnostics) + } + } + + pub(crate) fn with_local_endpoint(self, socket_path: &std::path::Path) -> Self { + self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) + } + + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { + self.preferences = preferences::load(&path).unwrap_or_default(); + self.preferences_path = Some(path); + self + } + + pub(super) fn apply_snapshot_keybindings( + &mut self, + profile: Option<&str>, + commands: &[crate::protocol::ClientShellCommand], + ) -> Result<(), String> { + let mut keybinds = match self.keybinding_source { + ClientShellKeybindingSource::Endpoint => crate::config::keybindings_from_profile_toml( + profile.ok_or("endpoint did not publish its keybindings")?, + )?, + ClientShellKeybindingSource::RemoteLocal => return Ok(()), + ClientShellKeybindingSource::Local => { + let mut config = crate::config::Config { + keys: self.local_keys.clone(), + ..Default::default() + }; + config.keys.command = commands + .iter() + .map(|command| crate::config::CommandKeybindConfig { + key: if command.binding_labels.len() == 1 { + crate::config::BindingConfig::One(command.binding_labels[0].clone()) + } else { + crate::config::BindingConfig::Many(command.binding_labels.clone()) + }, + // The client never executes this field; preserve the opaque endpoint ID + // through the shared config collision resolver. + command: command.command_id.clone(), + action_type: match command.action { + crate::protocol::ClientShellCommandAction::Shell => { + crate::config::CommandKeybindType::Shell + } + crate::protocol::ClientShellCommandAction::Pane => { + crate::config::CommandKeybindType::Pane + } + crate::protocol::ClientShellCommandAction::Popup => { + crate::config::CommandKeybindType::Popup + } + crate::protocol::ClientShellCommandAction::PluginAction => { + crate::config::CommandKeybindType::PluginAction + } + }, + description: command.description.clone(), + width: None, + height: None, + }) + .collect(); + config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .map_err(|diagnostics| diagnostics.join("; "))? + } + }; + if self.keybinding_source == ClientShellKeybindingSource::Endpoint { + for command in commands { + keybinds + .keybinds + .custom_commands + .push(crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::from_labels( + &command.binding_labels, + )?, + label: command.binding_label.clone(), + command: command.command_id.clone(), + action: command.action.into(), + description: command.description.clone(), + width: None, + height: None, + }); + } + } + self.keybinds = keybinds; + Ok(()) + } + + pub(super) fn apply_live_config( + &mut self, + config: &Config, + load_diagnostics: &[String], + invalid_sections: &[String], + ) -> Vec { + let mut diagnostics = load_diagnostics.to_vec(); + let invalid_section = + |section: &str| invalid_sections.iter().any(|invalid| invalid == section); + + if !invalid_section("keys") + && self.keybinding_source != ClientShellKeybindingSource::Endpoint + { + match config.live_keybinds_with_diagnostics() { + Ok((mut keybinds, keybind_diagnostics)) => { + self.local_keys = config.keys.clone(); + if self.keybinding_source == ClientShellKeybindingSource::RemoteLocal { + keybinds.keybinds.custom_commands.clear(); + } + self.keybinds = keybinds; + diagnostics.extend(keybind_diagnostics); + } + Err(keybind_diagnostics) => diagnostics.extend( + keybind_diagnostics + .into_iter() + .map(|diagnostic| format!("{diagnostic}; kept current keybinds")), + ), + } + } + + if !invalid_section("ui") { + if let Some(diagnostic) = config.invalid_sidebar_bounds_diagnostic() { + diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); + } else { + let ui = &config.ui; + diagnostics.extend(ui.sound.diagnostics()); + self.sidebar_width = ui.sidebar_width; + self.sidebar_min_width = ui.sidebar_min_width; + self.sidebar_max_width = ui.sidebar_max_width; + self.sidebar_collapsed_mode = ui.sidebar_collapsed_mode; + self.mobile_width_threshold = ui.mobile_width_threshold; + self.tab_bar_position = ui.tab_bar_position; + self.hide_tab_bar_when_single_tab = ui.hide_tab_bar_when_single_tab; + self.spaces = ui.sidebar.spaces.clone(); + self.agents = ui.sidebar.agents.clone(); + self.agent_panel_sort = ui.agent_panel_sort; + self.status_indicators = ui.status_indicators; + self.sound_enabled = ui.sound.enabled; + self.toast_delivery = ui.toast.delivery; + self.toast_delay_seconds = ui.toast.delay_seconds; + self.toast_position = ui.toast.herdr.position; + self.copy_on_select = ui.copy_on_select; + self.clipboard_toast_enabled = ui.toast.clipboard.enabled; + self.clipboard_toast_position = ui.toast.clipboard.position; + self.prompt_new_tab_name = ui.prompt_new_tab_name; + self.prompt_new_workspace_name = ui.prompt_new_workspace_name; + self.confirm_close = ui.confirm_close; + self.mouse_capture = ui.mouse_capture; + self.mouse_scroll_lines = ui.mouse_scroll_lines(); + self.right_click_passthrough_modifiers = ui.right_click_passthrough_modifiers(); + self.redraw_on_focus_gained = ui.redraw_on_focus_gained; + } + } + + if !invalid_section("theme") { + self.theme_runtime = crate::app::client_theme_runtime_from_config(config); + self.theme_name = self.theme_runtime.manual_name.clone(); + self.palette = crate::app::client_palette_from_config(config); + } + if !invalid_section("experimental") { + self.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; + } + + diagnostics + } + + pub(super) fn layout( + &self, + cols: u16, + rows: u16, + sidebar_collapsed: bool, + tab_count: usize, + sidebar_width: u16, + ) -> ClientShellLayout { + if cols <= self.mobile_width_threshold { + let header_height = rows.min(2); + return ClientShellLayout { + sidebar: Rect::default(), + tab_bar: Rect::default(), + mobile_header: Rect::new(0, 0, cols, header_height), + pane_surface: Rect::new(0, header_height, cols, rows.saturating_sub(header_height)), + }; + } + + let sidebar_width = if sidebar_collapsed { + match self.sidebar_collapsed_mode { + SidebarCollapsedModeConfig::Compact => 4, + SidebarCollapsedModeConfig::Hidden => 0, + } + } else { + let (min, max) = crate::config::validated_sidebar_bounds( + self.sidebar_min_width, + self.sidebar_max_width, + ) + .unwrap_or((18, 36)); + sidebar_width.clamp(min, max) + } + .min(cols.saturating_sub(1)); + let main = Rect::new(sidebar_width, 0, cols.saturating_sub(sidebar_width), rows); + let show_tab_bar = rows > 1 && !(self.hide_tab_bar_when_single_tab && tab_count == 1); + let tab_height = u16::from(show_tab_bar); + let (tab_bar, pane_surface) = match self.tab_bar_position { + TabBarPositionConfig::Top => ( + Rect::new(main.x, 0, main.width, tab_height), + Rect::new( + main.x, + tab_height, + main.width, + rows.saturating_sub(tab_height), + ), + ), + TabBarPositionConfig::Bottom => ( + Rect::new( + main.x, + rows.saturating_sub(tab_height), + main.width, + tab_height, + ), + Rect::new(main.x, 0, main.width, rows.saturating_sub(tab_height)), + ), + }; + + ClientShellLayout { + sidebar: Rect::new(0, 0, sidebar_width, rows), + tab_bar, + mobile_header: Rect::default(), + pane_surface, + } + } + + pub(crate) fn initial_surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let sidebar_collapsed = self + .preferences + .sidebar_collapsed + .unwrap_or(self.sidebar_start_collapsed); + let (min_width, max_width) = + crate::config::validated_sidebar_bounds(self.sidebar_min_width, self.sidebar_max_width) + .unwrap_or((18, 36)); + let sidebar_width = self + .preferences + .sidebar_width + .unwrap_or(self.sidebar_width) + .clamp(min_width, max_width); + let surface = self + .layout(cols, rows, sidebar_collapsed, 0, sidebar_width) + .pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn live_reload_applies_client_owned_sections() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + let mut next = Config::default(); + next.ui.sidebar_width = 31; + next.ui.tab_bar_position = TabBarPositionConfig::Bottom; + next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + next.ui.sidebar.agents.row_gap = 2; + next.keys.prefix = "ctrl+a".to_owned(); + + let diagnostics = shell.apply_live_config(&next, &[], &[]); + + assert!(diagnostics.is_empty()); + assert_eq!(shell.sidebar_width, 31); + assert_eq!(shell.tab_bar_position, TabBarPositionConfig::Bottom); + assert_eq!( + shell.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert_eq!( + shell.status_indicators, + crate::config::StatusIndicatorStyle::Symbols + ); + assert_eq!(shell.agents.row_gap, 2); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + } + + #[test] + fn initial_surface_size_uses_persisted_endpoint_chrome() { + let path = std::env::temp_dir().join(format!( + "herdr-initial-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + preferences::store( + &path, + preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..preferences::ClientChromePreferences::default() + }, + ) + .expect("persist endpoint chrome"); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let initial = config.initial_surface_size(100, 30); + let state = ClientShellState::new(config); + assert_eq!(initial, state.surface_size(100, 30)); + std::fs::remove_file(path).expect("remove endpoint chrome"); + } + + #[test] + fn live_reload_preserves_invalid_client_owned_sections() { + let mut initial = Config::default(); + initial.ui.sidebar_width = 29; + initial.keys.prefix = "ctrl+x".to_owned(); + let mut shell = ClientShellConfig::from_config(&initial); + + let mut invalid = Config::default(); + invalid.ui.sidebar_width = 35; + invalid.keys.prefix = "ctrl+a".to_owned(); + let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; + shell.apply_live_config(&invalid, &[], &invalid_sections); + + assert_eq!(shell.sidebar_width, 29); + assert_eq!( + shell.keybinds.prefix, + (KeyCode::Char('x'), KeyModifiers::CONTROL) + ); + } +} diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs new file mode 100644 index 00000000..af271bb6 --- /dev/null +++ b/src/client/shell/context_menu.rs @@ -0,0 +1,469 @@ +use super::*; + +impl ClientContextMenuOverlay { + pub(super) fn items(&self) -> Vec { + use ClientContextMenuAction as Action; + + let item = |label, action| ClientContextMenuItem { label, action }; + match &self.target { + ClientContextMenuTarget::Workspace { is_git: false, .. } => { + vec![item("Rename", Action::Rename), item("Close", Action::Close)] + } + ClientContextMenuTarget::Workspace { + is_linked_worktree: false, + has_worktree_children: false, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + ], + ClientContextMenuTarget::Workspace { + is_linked_worktree: true, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close", Action::Close), + item("Delete worktree checkout...", Action::RemoveWorktree), + ], + ClientContextMenuTarget::Workspace { + has_worktree_children: true, + collapsed, + .. + } => vec![ + item("Rename", Action::Rename), + item("Close group", Action::Close), + item("New worktree", Action::NewWorktree), + item("Open worktree...", Action::OpenWorktree), + item( + if *collapsed { "Expand" } else { "Collapse" }, + Action::ToggleGroup, + ), + ], + ClientContextMenuTarget::Tab { .. } => vec![ + item("New tab", Action::NewTab), + item("Rename", Action::Rename), + item("Close", Action::Close), + ], + ClientContextMenuTarget::Pane { + source_pane_id, + has_manual_label, + right_click_passthrough, + .. + } => { + let mut items = vec![item("Rename pane", Action::RenamePane)]; + if *has_manual_label { + items.push(item("Clear pane name", Action::ClearPaneName)); + } + if source_pane_id.is_some() { + items.push(item("Swap with focused pane", Action::SwapWithFocusedPane)); + } + items.extend([ + item("Split right", Action::SplitRight), + item("Split down", Action::SplitDown), + item("Zoom", Action::Zoom), + item( + if *right_click_passthrough { + "Use Herdr right-click menu" + } else { + "Send right-clicks to pane" + }, + Action::ToggleRightClickPassthrough, + ), + item("Close pane", Action::ClosePane), + ]); + items + } + } + } +} + +impl ClientShellState { + pub(super) fn open_workspace_context_menu(&mut self, workspace_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let worktree = workspace.worktree.as_ref(); + let has_worktree_children = worktree.is_some_and(|worktree| { + !worktree.is_linked_worktree + && snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2 + }); + let collapsed = + worktree.is_some_and(|worktree| self.collapsed_groups.contains(&worktree.key)); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { + workspace_id, + is_git: worktree.is_some() || workspace.branch.is_some(), + is_linked_worktree: worktree.is_some_and(|worktree| worktree.is_linked_worktree), + has_worktree_children, + collapsed, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_tab_context_menu(&mut self, tab_id: String, x: u16, y: u16) { + let Some(tab) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Tab { + tab_id, + workspace_id: tab.workspace_id.clone(), + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn open_pane_context_menu(&mut self, pane_id: String, x: u16, y: u16) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + let source_pane_id = snapshot + .focused_pane_id + .clone() + .filter(|focused| focused != &pane_id); + self.overlay = Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Pane { + pane_id, + workspace_id: pane.workspace_id.clone(), + source_pane_id, + has_manual_label: pane.label.is_some(), + right_click_passthrough: pane.right_click_passthrough, + }, + x, + y, + highlighted: 0, + })); + } + + pub(super) fn move_context_menu_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.as_mut() else { + return; + }; + let item_count = menu.items().len(); + if item_count == 0 { + return; + } + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_context_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(ClientShellOverlay::ContextMenu(menu)) = self.overlay.take() else { + return; + }; + let Some(action) = menu.items().get(index).map(|item| item.action) else { + outcome.repaint = true; + return; + }; + match menu.target { + ClientContextMenuTarget::Workspace { workspace_id, .. } => { + self.activate_workspace_context_action(workspace_id, action, outcome) + } + ClientContextMenuTarget::Tab { + tab_id, + workspace_id, + } => self.activate_tab_context_action(tab_id, workspace_id, action, outcome), + ClientContextMenuTarget::Pane { + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + .. + } => self.activate_pane_context_action( + pane_id, + workspace_id, + source_pane_id, + right_click_passthrough, + action, + outcome, + ), + } + outcome.repaint = true; + } + + fn activate_workspace_context_action( + &mut self, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::input::KeybindAction; + + match action { + ClientContextMenuAction::Rename => { + let label = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }) + .map(|workspace| workspace.label.clone()); + if let Some(label) = label { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: label, + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + } + ClientContextMenuAction::Close => { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + ClientContextMenuAction::NewWorktree => { + self.begin_worktree_action_for(KeybindAction::NewWorktree, workspace_id, outcome) + } + ClientContextMenuAction::OpenWorktree => { + self.begin_worktree_action_for(KeybindAction::OpenWorktree, workspace_id, outcome) + } + ClientContextMenuAction::RemoveWorktree => { + self.begin_worktree_action_for(KeybindAction::RemoveWorktree, workspace_id, outcome) + } + ClientContextMenuAction::ToggleGroup => { + let key = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .and_then(|workspace| workspace.worktree.as_ref()) + .map(|worktree| worktree.key.clone()) + }); + if let Some(key) = key { + if !self.collapsed_groups.remove(&key) { + self.collapsed_groups.insert(key); + } + self.persist_chrome_preferences(outcome); + } + } + _ => {} + } + } + + fn activate_tab_context_action( + &mut self, + tab_id: String, + workspace_id: String, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, TabTarget}; + + self.push_endpoint_method( + Method::TabFocus(TabTarget { + tab_id: tab_id.clone(), + }), + outcome, + ); + match action { + ClientContextMenuAction::NewTab => { + if self.config.prompt_new_tab_name { + let default_name = (self + .snapshot + .as_deref() + .map(|snapshot| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + .unwrap_or(0) + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } else { + self.push_endpoint_method( + Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }), + outcome, + ); + } + } + ClientContextMenuAction::Rename => { + let tab = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)); + if let Some(tab) = tab { + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id, + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + } + ClientContextMenuAction::Close => { + self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome); + } + _ => {} + } + } + + fn activate_pane_context_action( + &mut self, + pane_id: String, + workspace_id: String, + source_pane_id: Option, + right_click_passthrough: bool, + action: ClientContextMenuAction, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{ + Method, PaneInputSetParams, PaneRenameParams, PaneRightClickTarget, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + }; + + match action { + ClientContextMenuAction::RenamePane => { + let label = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == pane_id) + .and_then(|pane| pane.label.clone()) + }); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: label.clone().unwrap_or_default(), + replace_on_type: label.is_none(), + target: ClientRenameTarget::Pane { pane_id }, + })); + } + ClientContextMenuAction::ClearPaneName => self.push_endpoint_method( + Method::PaneRename(PaneRenameParams { + pane_id, + label: None, + }), + outcome, + ), + ClientContextMenuAction::SwapWithFocusedPane => { + if let Some(source_pane_id) = source_pane_id { + self.push_endpoint_method( + Method::PaneSwap(PaneSwapParams { + pane_id: None, + direction: None, + source_pane_id: Some(source_pane_id.clone()), + target_pane_id: Some(pane_id), + }), + outcome, + ); + self.push_endpoint_method( + Method::PaneFocus(PaneTarget { + pane_id: source_pane_id, + }), + outcome, + ); + } + } + ClientContextMenuAction::SplitRight | ClientContextMenuAction::SplitDown => { + self.push_endpoint_method( + Method::PaneSplit(PaneSplitParams { + workspace_id: Some(workspace_id), + target_pane_id: Some(pane_id), + direction: if action == ClientContextMenuAction::SplitRight { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + }), + outcome, + ); + } + ClientContextMenuAction::Zoom => self.push_endpoint_method( + Method::PaneZoom(PaneZoomParams { + pane_id: Some(pane_id), + mode: PaneZoomMode::Toggle, + }), + outcome, + ), + ClientContextMenuAction::ToggleRightClickPassthrough => self.push_endpoint_method( + Method::PaneInputSet(PaneInputSetParams { + pane_id, + right_click: if right_click_passthrough { + PaneRightClickTarget::Herdr + } else { + PaneRightClickTarget::Pane + }, + }), + outcome, + ), + ClientContextMenuAction::ClosePane => { + self.push_endpoint_method(Method::PaneClose(PaneTarget { pane_id }), outcome) + } + _ => {} + } + } +} diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs new file mode 100644 index 00000000..c39a7507 --- /dev/null +++ b/src/client/shell/copy_mode.rs @@ -0,0 +1,871 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +impl ClientShellState { + pub(super) fn reset_copy_pipeline(&mut self) { + self.copy_session_generation = self.copy_session_generation.saturating_add(1); + self.copy_operation_in_flight = false; + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + + pub(super) fn enter_copy_mode(&mut self, outcome: &mut ClientShellInput) -> bool { + let pane_id = match self.focused_pane_id() { + Some(pane_id) => pane_id, + None => return false, + }; + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.pane_id == pane_id) + { + self.mode = ClientShellMode::Copy; + return true; + } + if self.copy_mode.is_some() { + self.exit_copy_mode(false, outcome); + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + else { + return false; + }; + let Some(metrics) = hit.scroll else { + return false; + }; + let viewport_top = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let cursor = self + .pane_surface + .as_ref() + .and_then(|surface| { + let pane = surface.panes.iter().find(|pane| pane.pane_id == pane_id)?; + let cursor = surface + .frame + .cursor + .as_ref() + .filter(|cursor| cursor.visible)?; + let inner = pane.inner_rect; + (cursor.x >= inner.x + && cursor.x < inner.x.saturating_add(inner.width) + && cursor.y >= inner.y + && cursor.y < inner.y.saturating_add(inner.height)) + .then_some(crate::api::schema::PaneTextPoint { + row: viewport_top.saturating_add(u32::from(cursor.y - inner.y)), + col: cursor.x - inner.x, + }) + }) + .unwrap_or(crate::api::schema::PaneTextPoint { + row: viewport_top + .saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))), + col: 0, + }); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.reset_copy_pipeline(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map_or(0, |pane| pane.content_revision); + self.copy_mode = Some(ClientCopyModeState { + pane_id, + content_revision, + geometry: (hit.inner_rect.width, hit.inner_rect.height), + cursor, + offset_from_bottom: metrics.offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + entry_offset_from_bottom: metrics.offset_from_bottom, + selection: None, + search_prompt: None, + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + self.mode = ClientShellMode::Copy; + true + } + + pub(super) fn route_copy_mode_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.route_copy_search_prompt_key(key, outcome) { + return; + } + match key.code { + KeyCode::Esc => { + let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.selection.is_some() + || !copy_mode.search_query.is_empty() + || !copy_mode.search_matches.is_empty() + || copy_mode.search_direction.is_some() + }); + if should_clear { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.selection = None; + copy_mode.search_query.clear(); + copy_mode.search_direction = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + } + self.selection = None; + } else { + self.exit_copy_mode(false, outcome); + } + outcome.repaint = true; + return; + } + KeyCode::Enter => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + return; + } + KeyCode::Left => { + self.move_copy_cursor(0, -1, outcome); + return; + } + KeyCode::Down => { + self.move_copy_cursor(1, 0, outcome); + return; + } + KeyCode::Up => { + self.move_copy_cursor(-1, 0, outcome); + return; + } + KeyCode::Right => { + self.move_copy_cursor(0, 1, outcome); + return; + } + KeyCode::PageUp => { + self.move_copy_page(-1, false, outcome); + return; + } + KeyCode::PageDown => { + self.move_copy_page(1, false, outcome); + return; + } + KeyCode::Home => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + return; + } + KeyCode::End => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome); + return; + } + _ => {} + } + + match (key.code, key.modifiers) { + (KeyCode::Char('b'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, false, outcome); + return; + } + (KeyCode::Char('f'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, false, outcome); + return; + } + (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(-1, true, outcome); + return; + } + (KeyCode::Char('d'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { + self.move_copy_page(1, true, outcome); + return; + } + _ => {} + } + + let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else { + return; + }; + match command { + 'q' => self.exit_copy_mode(false, outcome), + 'y' => { + if !self.defer_copy_until_search_result() { + self.exit_copy_mode(true, outcome); + } + } + 'v' | ' ' => self.begin_copy_selection(false), + 'V' => self.begin_copy_selection(true), + 'h' => self.move_copy_cursor(0, -1, outcome), + 'j' => self.move_copy_cursor(1, 0, outcome), + 'k' => self.move_copy_cursor(-1, 0, outcome), + 'l' => self.move_copy_cursor(0, 1, outcome), + 'g' => self.move_copy_history(true, outcome), + 'G' => self.move_copy_history(false, outcome), + '0' => { + self.set_copy_cursor_col(0); + self.sync_copy_selection(); + outcome.repaint = true; + } + '$' => self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome), + '^' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::FirstNonBlank, outcome) + } + '/' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Forward), + '?' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Backward), + 'n' => self.repeat_copy_search(false, outcome), + 'N' => self.repeat_copy_search(true, outcome), + 'w' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordStart, outcome) + } + 'b' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousWordStart, + outcome, + ), + 'e' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordEnd, outcome) + } + 'W' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::NextBigWordStart, + outcome, + ), + 'B' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousBigWordStart, + outcome, + ), + 'E' => self + .request_copy_motion(crate::api::schema::PaneCopyMotion::NextBigWordEnd, outcome), + '{' => self.request_copy_motion( + crate::api::schema::PaneCopyMotion::PreviousParagraph, + outcome, + ), + '}' => { + self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextParagraph, outcome) + } + _ => return, + } + outcome.repaint = true; + } + + fn route_copy_search_prompt_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(prompt) = self + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + else { + return false; + }; + let mut submit = None; + match key.code { + KeyCode::Esc => { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Enter => { + submit = Some((prompt.query.clone(), prompt.direction)); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search_prompt = None; + } + } + KeyCode::Backspace => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.pop(); + } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.clear(); + } + } + _ => { + if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) { + if let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + { + prompt.query.push(ch); + } + } + } + } + if let Some((query, direction)) = submit { + self.request_copy_search(query, direction, false, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool { + let Some(prompt) = self + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search_prompt.as_mut()) + else { + return false; + }; + prompt + .query + .extend(text.chars().filter(|character| !character.is_control())); + true + } + + fn open_copy_search(&mut self, direction: crate::api::schema::PaneCopySearchDirection) { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + copy_mode.search_prompt = Some(ClientCopySearchPrompt { + direction, + query: String::new(), + }); + } + + fn repeat_copy_search(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + if copy_mode.search_query.is_empty() { + return; + } + let Some(direction) = copy_mode.search_direction else { + return; + }; + let direction = if reverse { + match direction { + crate::api::schema::PaneCopySearchDirection::Forward => { + crate::api::schema::PaneCopySearchDirection::Backward + } + crate::api::schema::PaneCopySearchDirection::Backward => { + crate::api::schema::PaneCopySearchDirection::Forward + } + } + } else { + direction + }; + self.request_copy_search(copy_mode.search_query.clone(), direction, true, outcome); + } + + fn defer_copy_until_search_result(&mut self) -> bool { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return false; + }; + let pane_id = copy_mode.pane_id.clone(); + let generation = copy_mode.search_generation; + let pending = self.pending_requests.values().any(|pending| { + matches!( + &pending.kind, + PendingEndpointKind::CopySearch { + pane_id: pending_pane, + generation: pending_generation, + .. + } if pending_pane == &pane_id && *pending_generation == generation + ) + }) || self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + if pending { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.copy_after_search = true; + } + } + pending + } + + fn request_copy_search( + &mut self, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + outcome: &mut ClientShellInput, + ) { + if query.is_empty() || self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Search { + query, + direction, + repeat, + }); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn apply_copy_search_result( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + result: ClientCopySearchResult, + outcome: &mut ClientShellInput, + ) -> bool { + let search_queued = self + .copy_operation_queue + .iter() + .any(|operation| matches!(operation, ClientCopyOperation::Search { .. })); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != result.content_revision + || copy_mode.search_generation != generation + { + return false; + } + let current = result.current.filter(|index| *index < result.matches.len()); + copy_mode.search_query = query; + if !repeat { + copy_mode.search_direction = Some(direction); + } + copy_mode.search_matches = result.matches; + copy_mode.search_total = result.total; + copy_mode.search_current = current; + copy_mode.search_current_global = result.current_global; + let target = current.and_then(|index| copy_mode.search_matches.get(index).copied()); + let copy_after_search = if search_queued { + false + } else { + std::mem::take(&mut copy_mode.copy_after_search) + }; + if let Some(target) = target { + copy_mode.cursor = target.start; + self.reveal_copy_cursor(outcome, true); + self.sync_copy_selection(); + } + if copy_after_search { + self.exit_copy_mode(true, outcome); + } + outcome.repaint = true; + true + } + + pub(super) fn complete_copy_operation( + &mut self, + session_generation: u64, + continue_queue: bool, + outcome: &mut ClientShellInput, + ) { + if self.copy_session_generation != session_generation { + return; + } + self.copy_operation_in_flight = false; + if continue_queue && self.copy_mode.is_some() { + self.dispatch_next_copy_operation(outcome); + self.dispatch_queued_copy_input(outcome); + } else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + } + } + + fn dispatch_queued_copy_input(&mut self, outcome: &mut ClientShellInput) { + while !self.copy_operation_in_flight { + let Some(key) = self.copy_input_queue.pop_front() else { + return; + }; + self.handle_key(key, outcome); + } + } + + pub(super) fn cancel_deferred_copy_after_search(&mut self, generation: u64) { + if let Some(copy_mode) = self + .copy_mode + .as_mut() + .filter(|copy_mode| copy_mode.search_generation == generation) + { + copy_mode.copy_after_search = false; + } + } + + fn copy_hit(&self) -> Option { + let pane_id = self.copy_mode.as_ref()?.pane_id.as_str(); + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == pane_id) + .cloned() + } + + fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + self.exit_copy_mode(false, outcome); + return; + }; + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if col_delta < 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_sub(col_delta.unsigned_abs()); + } else if col_delta > 0 { + copy_mode.cursor.col = copy_mode + .cursor + .col + .saturating_add(col_delta as u16) + .min(hit.inner_rect.width.saturating_sub(1)); + } + let total_rows = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .max(1); + if row_delta < 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_sub(u32::from(row_delta.unsigned_abs())); + } else if row_delta > 0 { + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(u32::from(row_delta as u16)) + .min(total_rows.saturating_sub(1).min(u32::MAX as usize) as u32); + } + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_page(&mut self, direction: i8, half_page: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let lines = crate::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page); + let Some((pane_id, next_offset)) = self.copy_mode.as_mut().map(|copy_mode| { + if direction < 0 { + copy_mode.cursor.row = copy_mode.cursor.row.saturating_sub(lines as u32); + copy_mode.offset_from_bottom = copy_mode + .offset_from_bottom + .saturating_add(lines) + .min(copy_mode.max_offset_from_bottom); + } else { + let last_row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.cursor.row = copy_mode + .cursor + .row + .saturating_add(lines as u32) + .min(last_row); + copy_mode.offset_from_bottom = copy_mode.offset_from_bottom.saturating_sub(lines); + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, next_offset, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn move_copy_history(&mut self, top: bool, outcome: &mut ClientShellInput) { + let Some(hit) = self.copy_hit() else { + return; + }; + let Some((pane_id, offset_from_bottom)) = self.copy_mode.as_mut().map(|copy_mode| { + if top { + copy_mode.cursor.row = 0; + copy_mode.offset_from_bottom = copy_mode.max_offset_from_bottom; + } else { + copy_mode.cursor.row = copy_mode + .max_offset_from_bottom + .saturating_add(hit.inner_rect.height as usize) + .saturating_sub(1) + .min(u32::MAX as usize) as u32; + copy_mode.offset_from_bottom = 0; + } + (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom) + }) else { + return; + }; + self.push_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + self.sync_copy_selection(); + outcome.repaint = true; + } + + fn set_copy_cursor_col(&mut self, col: u16) { + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor.col = col; + } + } + + fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) { + let Some(hit) = self.copy_hit() else { + return; + }; + let request = self.copy_mode.as_mut().and_then(|copy_mode| { + let current_top = copy_mode + .max_offset_from_bottom + .saturating_sub(copy_mode.offset_from_bottom) as u32; + let max_cursor_row = hit + .inner_rect + .height + .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 }); + let bottom = current_top.saturating_add(u32::from(max_cursor_row)); + let desired_top = if copy_mode.cursor.row < current_top { + copy_mode.cursor.row + } else if copy_mode.cursor.row > bottom { + copy_mode + .cursor + .row + .saturating_sub(u32::from(max_cursor_row)) + } else { + current_top + }; + let offset = copy_mode + .max_offset_from_bottom + .saturating_sub(desired_top as usize); + if offset == copy_mode.offset_from_bottom { + return None; + } + copy_mode.offset_from_bottom = offset; + Some((copy_mode.pane_id.clone(), offset)) + }); + if let Some((pane_id, offset)) = request { + self.push_pane_scroll_offset(pane_id, offset, outcome); + } + } + + fn begin_copy_selection(&mut self, linewise: bool) { + let end_col = self + .copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if linewise { + copy_mode.selection = Some(ClientCopySelection::Linewise { + anchor_row: copy_mode.cursor.row, + }); + self.selection = Some(crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + copy_mode.cursor.row, + copy_mode.cursor.row, + end_col, + )); + } else { + copy_mode.selection = Some(ClientCopySelection::Character { + anchor: copy_mode.cursor, + }); + self.selection = Some(crate::selection::Selection::absolute_anchor( + copy_mode.pane_id.clone(), + (copy_mode.cursor.row, copy_mode.cursor.col), + )); + } + } + + pub(super) fn sync_copy_selection(&mut self) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + let Some(selection) = copy_mode.selection else { + return; + }; + self.selection = Some(match selection { + ClientCopySelection::Character { anchor } => { + crate::selection::Selection::absolute_range( + copy_mode.pane_id.clone(), + (anchor.row, anchor.col), + (copy_mode.cursor.row, copy_mode.cursor.col), + ) + } + ClientCopySelection::Linewise { anchor_row } => { + crate::selection::Selection::line_range( + copy_mode.pane_id.clone(), + anchor_row, + copy_mode.cursor.row, + self.copy_hit() + .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)), + ) + } + }); + } + + fn request_copy_motion( + &mut self, + motion: crate::api::schema::PaneCopyMotion, + outcome: &mut ClientShellInput, + ) { + if self.copy_mode.is_none() { + return; + } + self.copy_operation_queue + .push_back(ClientCopyOperation::Motion(motion)); + self.dispatch_next_copy_operation(outcome); + } + + pub(super) fn dispatch_next_copy_operation(&mut self, outcome: &mut ClientShellInput) { + if self.copy_operation_in_flight { + return; + } + while let Some(operation) = self.copy_operation_queue.pop_front() { + let Some(copy_mode) = self.copy_mode.as_mut() else { + self.copy_operation_queue.clear(); + self.copy_input_queue.clear(); + return; + }; + let session_generation = self.copy_session_generation; + let pane_id = copy_mode.pane_id.clone(); + let origin = copy_mode.cursor; + let (method, kind) = match operation { + ClientCopyOperation::Motion(motion) => ( + crate::api::schema::Method::PaneCopyMotion( + crate::api::schema::PaneCopyMotionParams { + pane_id: pane_id.clone(), + cursor: origin, + motion, + content_revision: Some(copy_mode.content_revision), + }, + ), + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + }, + ), + ClientCopyOperation::Search { + query, + direction, + repeat, + } => { + if query.is_empty() { + continue; + } + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + let generation = copy_mode.search_generation; + let previous = repeat + .then(|| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .filter(|text_match| text_match.start == copy_mode.cursor) + }) + .flatten(); + ( + crate::api::schema::Method::PaneCopySearch( + crate::api::schema::PaneCopySearchParams { + pane_id: pane_id.clone(), + query: query.clone(), + direction, + cursor: origin, + content_revision: copy_mode.content_revision, + previous, + }, + ), + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + }, + ) + } + }; + self.copy_operation_in_flight = true; + self.push_endpoint_method_with_kind(method, kind, outcome); + return; + } + } + + pub(super) fn apply_copy_motion_target( + &mut self, + pane_id: &str, + origin: crate::api::schema::PaneTextPoint, + cursor: crate::api::schema::PaneTextPoint, + content_revision: u64, + outcome: &mut ClientShellInput, + ) -> bool { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + if copy_mode.pane_id != pane_id + || copy_mode.cursor != origin + || copy_mode.content_revision != content_revision + { + return false; + } + copy_mode.cursor = cursor; + self.reveal_copy_cursor(outcome, false); + self.sync_copy_selection(); + outcome.repaint = true; + true + } + + pub(super) fn exit_copy_mode(&mut self, copy: bool, outcome: &mut ClientShellInput) { + if copy + && !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + if let Some((pane_id, text_match)) = self.copy_mode.as_ref().and_then(|copy_mode| { + copy_mode + .search_current + .and_then(|index| copy_mode.search_matches.get(index).copied()) + .map(|text_match| (copy_mode.pane_id.clone(), text_match)) + }) { + self.selection = Some(crate::selection::Selection::absolute_range( + pane_id, + (text_match.start.row, text_match.start.col), + (text_match.end.row, text_match.end.col), + )); + } + } + let Some(copy_mode) = self.copy_mode.take() else { + return; + }; + self.reset_copy_pipeline(); + if copy + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy(outcome); + } + self.selection = None; + self.selection_highlight_clear_deadline = None; + self.push_pane_scroll_offset( + copy_mode.pane_id, + copy_mode.entry_offset_from_bottom, + outcome, + ); + self.mode = ClientShellMode::Terminal; + outcome.repaint = true; + } +} diff --git a/src/client/shell/global_menu.rs b/src/client/shell/global_menu.rs new file mode 100644 index 00000000..e5bd6f78 --- /dev/null +++ b/src/client/shell/global_menu.rs @@ -0,0 +1,110 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientGlobalMenuAction { + Binding(crate::input::KeybindAction), + WhatsNew, +} + +pub(super) fn global_menu_attention(snapshot: &ClientShellSnapshot) -> bool { + snapshot.update_available.is_some() || snapshot.integration_updates_available +} + +pub(super) fn global_menu_item_has_badge( + snapshot: &ClientShellSnapshot, + action: ClientGlobalMenuAction, +) -> bool { + (action == ClientGlobalMenuAction::WhatsNew && snapshot.update_available.is_some()) + || (action == ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings) + && snapshot.integration_updates_available) +} + +pub(super) fn global_menu_items( + snapshot: &ClientShellSnapshot, +) -> Vec<(&'static str, ClientGlobalMenuAction)> { + let mut items = vec![ + ( + "settings", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Settings), + ), + ( + "keybinds", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Help), + ), + ( + "reload config", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::ReloadConfig), + ), + ]; + if snapshot.update_available.is_some() || snapshot.latest_release_notes_available { + items.push(( + if snapshot.update_available.is_some() { + "update ready" + } else { + "what's new" + }, + ClientGlobalMenuAction::WhatsNew, + )); + } + items.push(( + "detach", + ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Detach), + )); + items +} + +impl ClientShellState { + pub(super) fn toggle_global_menu(&mut self) { + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + self.overlay = None; + } else { + self.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + } + } + + pub(super) fn move_global_menu_selection(&mut self, delta: isize) { + let item_count = self + .snapshot + .as_deref() + .map(global_menu_items) + .map_or(0, |items| items.len()); + let Some(ClientShellOverlay::GlobalMenu(menu)) = self.overlay.as_mut() else { + return; + }; + menu.highlighted = (menu.highlighted as isize + delta) + .clamp(0, item_count.saturating_sub(1) as isize) as usize; + } + + pub(super) fn activate_global_menu_item( + &mut self, + index: usize, + outcome: &mut ClientShellInput, + ) { + let Some(action) = self.snapshot.as_deref().and_then(|snapshot| { + global_menu_items(snapshot) + .get(index) + .map(|(_, action)| *action) + }) else { + return; + }; + if action == ClientGlobalMenuAction::WhatsNew + && self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + .is_none() + { + return; + } + self.overlay = None; + match action { + ClientGlobalMenuAction::Binding(binding) => { + self.record_binding(crate::input::KeybindMatch::Action(binding), outcome) + } + ClientGlobalMenuAction::WhatsNew => self.open_release_notes(), + } + outcome.repaint = true; + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs new file mode 100644 index 00000000..1fc23414 --- /dev/null +++ b/src/client/shell/graphics.rs @@ -0,0 +1,67 @@ +use super::*; + +impl ClientShellState { + #[cfg(unix)] + pub(crate) fn graphics_scope(&self) -> &str { + self.snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + .unwrap_or_default() + } + + #[cfg(unix)] + pub(crate) fn trust_direct_graphics_asset( + &mut self, + key: &crate::protocol::SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + self.graphics.trust_direct_asset(key, image_id) + } + + #[cfg(unix)] + pub(crate) fn retire_direct_graphics_image(&mut self, image_id: u32) { + self.graphics.retire_direct_image(image_id); + } + + pub(crate) fn take_pending_graphics_cleanup(&mut self) -> Vec { + self.graphics.take_pending_cleanup() + } + + pub(crate) fn set_graphics_cell_size(&mut self, width_px: u32, height_px: u32) { + self.graphics_cell_size = crate::kitty_graphics::HostCellSize { + width_px: width_px.max(1), + height_px: height_px.max(1), + }; + } + + 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_notification.is_some() + || self.copy_feedback.is_some() + || self + .selection + .as_ref() + .is_some_and(|selection| selection.is_visible()); + let visibility = if local_cover { + crate::kitty_graphics::surface::Visibility::Hidden + } else if self.hits.popup.is_some() { + crate::kitty_graphics::surface::Visibility::Popup + } else { + crate::kitty_graphics::surface::Visibility::Main + }; + let popup_origin = self + .hits + .popup + .as_ref() + .map(|popup| (popup.inner_rect.x, popup.inner_rect.y)); + frame.graphics = self.graphics.encode( + visibility, + (layout.pane_surface.x, layout.pane_surface.y), + popup_origin, + self.graphics_cell_size, + ); + } +} diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs new file mode 100644 index 00000000..d3af1480 --- /dev/null +++ b/src/client/shell/input.rs @@ -0,0 +1,1011 @@ +use super::*; +use crate::protocol::ClientPaneInputEvent; +use crate::raw_input::RawInputEvent; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + +const LOCAL_INPUT_SOURCE: u8 = 0; + +fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool { + matches!(key.code, KeyCode::Char('c' | 'C')) + && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) +} + +pub(super) fn is_modal_paste_shortcut_for_platform( + key: &crate::input::TerminalKey, + macos: bool, +) -> bool { + matches!(key.code, KeyCode::Char('v' | 'V')) + && (key.modifiers.contains(KeyModifiers::CONTROL) + || macos && key.modifiers.contains(KeyModifiers::SUPER)) +} + +fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool { + is_modal_paste_shortcut_for_platform(key, cfg!(target_os = "macos")) +} + +fn host_theme_update(event: &RawInputEvent) -> Option { + use crate::protocol::{ + ClientHostAppearance, ClientHostDefaultColorKind, ClientHostThemeUpdate, + }; + + match event { + RawInputEvent::HostDefaultColor { kind, color } => { + Some(ClientHostThemeUpdate::DefaultColor { + kind: match kind { + crate::terminal_theme::DefaultColorKind::Foreground => { + ClientHostDefaultColorKind::Foreground + } + crate::terminal_theme::DefaultColorKind::Background => { + ClientHostDefaultColorKind::Background + } + }, + color: (*color).into(), + }) + } + RawInputEvent::HostPaletteColors { colors } => Some(ClientHostThemeUpdate::PaletteColors( + colors + .iter() + .map(|(index, color)| (*index, (*color).into())) + .collect(), + )), + RawInputEvent::HostColorSchemeChanged(appearance) => { + Some(ClientHostThemeUpdate::Appearance(match appearance { + crate::terminal_theme::HostAppearance::Dark => ClientHostAppearance::Dark, + crate::terminal_theme::HostAppearance::Light => ClientHostAppearance::Light, + })) + } + _ => None, + } +} + +impl ClientShellState { + pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { + matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate + ) + } + + #[cfg(any(unix, 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)) + } + + #[cfg(any(unix, test))] + pub(crate) fn handle_pixel_mouse( + &mut self, + data: &[u8], + geometry: crate::input::mouse::HostGeometry, + ) -> ClientShellInput { + let Some((x, y)) = crate::input::mouse::parse_report(data) else { + return ClientShellInput::default(); + }; + let Some((column, row)) = geometry.cell(x, y) else { + return ClientShellInput::default(); + }; + let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { + return ClientShellInput::default(); + }; + let events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); + if events.len() != 1 || !matches!(events[0], RawInputEvent::Mouse(_)) { + return ClientShellInput::default(); + } + self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); + let outcome = self.handle_raw_events(events); + self.host_mouse_pixels = None; + outcome + } + + #[cfg(windows)] + pub(crate) fn handle_client_events( + &mut self, + events: &[crate::protocol::ClientInputEvent], + ) -> ClientShellInput { + self.handle_raw_events( + events + .iter() + .map(crate::protocol::ClientInputEvent::to_raw_input_event) + .collect(), + ) + } + + fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool { + if self.insert_copy_search_text(text) { + outcome.repaint = true; + return true; + } + self.pending_word_selection = None; + if self.copy_or_terminal_mode() != ClientShellMode::Copy && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + false + } + + pub(crate) fn replay_mouse_events( + &mut self, + events: Vec, + ) -> ClientShellInput { + self.replaying_url_click = true; + let outcome = + self.handle_raw_events(events.into_iter().map(RawInputEvent::Mouse).collect()); + self.replaying_url_click = false; + outcome + } + + pub(super) fn handle_raw_events(&mut self, events: Vec) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if !events.is_empty() && self.endpoint_error.take().is_some() { + outcome.repaint = true; + } + for event in events { + if let Some(update) = host_theme_update(&event) { + outcome + .requests + .push(ClientMessage::ClientShellHostTheme { update }); + } + match event { + RawInputEvent::Key(key) => self.handle_key(key, &mut outcome), + RawInputEvent::Text(text) => { + let text = text.into_string(); + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::TextCommit(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Paste(text) => { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + self.reconcile_input_source(); + continue; + } + if self.prepare_committed_text(&text, &mut outcome) { + self.reconcile_input_source(); + continue; + } + if let Some(target) = self.popup_input_target() { + super::push_target_event( + target, + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } else if !self.popup_pending { + if self.insert_overlay_text(&text) { + outcome.repaint = true; + } else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal { + self.push_focused_pane_event( + ClientPaneInputEvent::Paste(text), + &mut outcome, + ); + } + } + } + RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse, &mut outcome), + RawInputEvent::OuterFocusGained => { + self.outer_focused = Some(true); + outcome.query_host_appearance = true; + outcome.repaint |= self.config.redraw_on_focus_gained; + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: true }); + } + RawInputEvent::OuterFocusLost => { + self.outer_focused = Some(false); + self.release_input_leases(&mut outcome); + outcome + .requests + .push(ClientMessage::ClientShellFocus { focused: false }); + } + RawInputEvent::HostColorSchemeChanged(appearance) => { + self.host_appearance = Some(appearance); + self.host_appearance_explicit = true; + outcome.query_host_theme = true; + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color, + } if !self.host_appearance_explicit => { + let appearance = color.inferred_appearance(); + self.host_appearance = Some(appearance); + if self.config.theme_runtime.auto_switch { + self.config.palette = crate::app::client_palette_for_appearance( + &self.config.theme_runtime, + appearance, + ); + outcome.repaint = true; + } + } + RawInputEvent::HostDefaultColor { .. } + | RawInputEvent::HostPaletteColors { .. } + | RawInputEvent::HostCellSizeReport { .. } + | RawInputEvent::Unsupported => {} + } + self.reconcile_input_source(); + } + outcome.repaint |= self.resume_mobile_switcher_if_ready(); + outcome + } + + pub(super) fn handle_key( + &mut self, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if self.copy_operation_in_flight { + self.copy_input_queue.push_back(key); + return; + } + let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); + let key = self.input_leases.normalize_press(&lease_key, key); + match key.kind { + KeyEventKind::Press => { + let initial_context = self.input_context(); + let target = self.route_key_press(&key, outcome); + if let Some(target) = target.as_ref() { + self.push_pane_key(target.clone(), key.clone(), outcome); + } + let resulting_context = self.input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + Some(&initial_context), + Some(&resulting_context), + target, + ); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Repeat => { + let context = self.input_context(); + let plan = self + .input_leases + .plan_repeat(lease_key, &key, Some(&context)); + self.execute_repeat_plan(lease_key, key, plan, outcome); + } + KeyEventKind::Release => { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { + let release = lease + .key + .with_modifiers(key.modifiers) + .with_kind(KeyEventKind::Release); + self.push_pane_key(lease.target, release, outcome); + } else { + let _ = self.input_leases.remove(&lease_key); + } + } + } + } + + fn release_input_leases(&mut self, outcome: &mut ClientShellInput) { + for lease in self.input_leases.remove_source(LOCAL_INPUT_SOURCE) { + self.push_pane_key( + lease.target, + lease.key.with_kind(KeyEventKind::Release), + outcome, + ); + } + if let Some(gesture) = self.pane_mouse_gesture.take() { + let modifiers = gesture + .last_event + .modifiers + .difference(gesture.stripped_modifiers); + let geometry = matches!( + gesture.last_position, + crate::protocol::ClientMousePosition::Pixels { .. } + ) + .then_some(crate::protocol::ClientMouseGeometry { + cols: gesture.hit.inner_rect.width, + rows: gesture.hit.inner_rect.height, + width_px: gesture.hit.pixel_width, + height_px: gesture.hit.pixel_height, + }); + let target = if gesture.hit.popup { + ClientInputTarget::Popup(gesture.hit.pane_id) + } else { + ClientInputTarget::Pane(gesture.hit.pane_id) + }; + super::push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::from_crossterm(gesture.button), + ), + position: gesture.last_position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } + self.copy_input_queue.clear(); + } + + fn execute_repeat_plan( + &mut self, + lease_key: crate::input::InputLeaseKey, + key: crate::input::TerminalKey, + plan: crate::input::RepeatPlan, + outcome: &mut ClientShellInput, + ) { + match plan { + crate::input::RepeatPlan::Forwarded(target) => { + let pane_blocked_by_popup = matches!(&target, ClientInputTarget::Pane(_)) + && (self.popup_pending || self.popup_terminal_id.is_some()); + if !pane_blocked_by_popup { + self.push_pane_key(target, key, outcome); + } + } + crate::input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + for _ in 0..repetitions { + let current = self.input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + Some(¤t), + tracked, + ) { + break; + } + let repeated = key + .clone() + .with_repeat_count(1) + .with_kind(KeyEventKind::Repeat); + if let Some(target) = self.route_key_press(&repeated, outcome) { + self.push_pane_key(target, repeated, outcome); + } + } + } + crate::input::RepeatPlan::Ignore => {} + } + } + + pub(super) fn modal_paste_target_active(&self) -> bool { + if self.popup_pending || self.popup_input_target().is_some() { + return false; + } + if self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return self.overlay.is_none(); + } + matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Rename(_)) + | Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + creating: false, + .. + } + )) + | Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + opening: false, + .. + } + )) + | Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + | Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ) + } + + pub(super) fn handle_modal_paste_shortcut_with( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + read_clipboard_text: impl FnOnce() -> Option, + ) -> bool { + if !is_modal_paste_shortcut(key) || !self.modal_paste_target_active() { + return false; + } + if let Some(text) = read_clipboard_text() { + let inserted = self.insert_copy_search_text(&text) || self.insert_overlay_text(&text); + outcome.repaint |= inserted; + } + true + } + + fn route_key_press( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> Option { + if self.handle_modal_paste_shortcut_with(key, outcome, crate::platform::read_clipboard_text) + { + return None; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) { + if key.kind == KeyEventKind::Press { + self.route_overlay_key(key, outcome); + } + return None; + } + if let Some(target) = self.popup_input_target() { + return Some(target); + } + if self.popup_pending { + return None; + } + if self.overlay.is_some() { + self.route_overlay_key(key, outcome); + return None; + } + if matches!(key.code, KeyCode::Modifier(_)) { + return None; + } + self.pending_word_selection = None; + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && !self.config.copy_on_select + && is_retained_selection_copy_key(key) + && self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible) + { + self.request_selection_copy_with_fallback(outcome, Some(key.clone())); + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + return None; + } + if self.mode != ClientShellMode::Copy + && self.copy_or_terminal_mode() != ClientShellMode::Copy + && self.selection.take().is_some() + { + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + outcome.repaint = true; + } + + match self.mode { + ClientShellMode::Terminal => { + if let Some(binding) = + crate::input::resolve_direct_binding(&self.config.keybinds.keybinds, key) + { + self.record_binding(binding, outcome); + return None; + } + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + return None; + } + self.focused_pane_id().map(ClientInputTarget::Pane) + } + ClientShellMode::Prefix => { + let return_mode = if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + }; + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = return_mode; + outcome.repaint = true; + return self.focused_pane_id().map(ClientInputTarget::Pane); + } + if key.code == KeyCode::Esc { + self.mode = return_mode; + outcome.repaint = true; + return None; + } + if let Some(binding) = + crate::input::resolve_prefix_binding(&self.config.keybinds.keybinds, key) + { + self.mode = return_mode; + outcome.repaint = true; + self.record_binding(binding, outcome); + return None; + } + self.mode = return_mode; + outcome.repaint = true; + None + } + ClientShellMode::Navigate => { + self.route_navigate_key(key, outcome); + None + } + ClientShellMode::Resize => { + self.route_resize_key(key, outcome); + None + } + ClientShellMode::Copy => { + if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + self.mode = ClientShellMode::Prefix; + outcome.repaint = true; + } else { + self.route_copy_mode_key(key, outcome); + } + None + } + } + } + + pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode { + if self.copy_mode.as_ref().is_some_and(|copy_mode| { + self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str()) + }) { + ClientShellMode::Copy + } else { + ClientShellMode::Terminal + } + } + + fn route_navigate_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; + + if key.code == KeyCode::Esc + || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + outcome.repaint = true; + return; + } + + if self + .config + .keybinds + .keybinds + .navigate + .workspace_up + .matches_direct_key(key) + { + self.move_navigate_workspace(-1); + outcome.repaint = true; + return; + } + if self + .config + .keybinds + .keybinds + .navigate + .workspace_down + .matches_direct_key(key) + { + self.move_navigate_workspace(1); + outcome.repaint = true; + return; + } + + if let Some(index) = ('1'..='9').position(|digit| { + crate::config::terminal_key_matches_combo( + key, + (KeyCode::Char(digit), KeyModifiers::empty()), + ) + }) { + let valid = self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(index) + .is_some() + }); + if valid { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.record_binding( + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)), + outcome, + ); + outcome.repaint = true; + } + return; + } + + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if modifiers.is_empty() { + match code { + KeyCode::Enter => { + let selected = self.navigate_workspace_id.clone(); + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if let Some(workspace_id) = selected { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + KeyCode::Tab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePaneNext), + false, + outcome, + ); + return; + } + KeyCode::BackTab => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::CyclePanePrevious), + false, + outcome, + ); + return; + } + KeyCode::Left => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneLeft), + true, + outcome, + ); + return; + } + KeyCode::Right => { + self.record_navigate_binding( + KeybindMatch::Action(KeybindAction::FocusPaneRight), + true, + outcome, + ); + return; + } + _ => {} + } + } + + let pane_action = [ + ( + &self.config.keybinds.keybinds.navigate.pane_left, + KeybindAction::FocusPaneLeft, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_down, + KeybindAction::FocusPaneDown, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_up, + KeybindAction::FocusPaneUp, + ), + ( + &self.config.keybinds.keybinds.navigate.pane_right, + KeybindAction::FocusPaneRight, + ), + ] + .into_iter() + .find_map(|(bindings, action)| bindings.matches_direct_key(key).then_some(action)); + if let Some(action) = pane_action { + self.record_navigate_binding(KeybindMatch::Action(action), true, outcome); + return; + } + + let binding = crate::input::resolve_non_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .filter(|action| { + !matches!( + action, + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight + ) + }) + .map(KeybindMatch::Action) + .or_else(|| { + crate::input::resolve_custom_command( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Command) + }) + .or_else(|| { + crate::input::resolve_indexed_action( + &self.config.keybinds.keybinds, + key, + KeybindDispatch::Prefix, + ) + .map(KeybindMatch::Action) + }); + if let Some(binding) = binding { + self.record_navigate_binding(binding, false, outcome); + } + } + + fn record_navigate_binding( + &mut self, + binding: crate::input::KeybindMatch, + preserve_navigate: bool, + outcome: &mut ClientShellInput, + ) { + use crate::input::{KeybindAction, KeybindMatch}; + + let indexed_target_exists = match &binding { + KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + self.navigation_workspace_entries(snapshot) + .get(*index) + .is_some() + }) + } + KeybindMatch::Action(KeybindAction::SwitchTab(index)) => self + .snapshot + .as_deref() + .and_then(|snapshot| { + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .nth(*index) + }) + .is_some(), + KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { + self.snapshot.as_deref().is_some_and(|snapshot| { + super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() + }) + } + _ => true, + }; + if !indexed_target_exists { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + fn move_navigate_workspace(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let mobile = self.mobile_layout_active(); + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return; + } + let current = self + .navigate_workspace_id + .as_deref() + .and_then(|selected| { + entries + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == selected) + }) + .unwrap_or(0); + let next = if mobile { + (current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize + } else { + (current as isize + delta).rem_euclid(entries.len() as isize) as usize + }; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.navigate_workspace_id = Some(workspace_id.clone()); + self.reveal_mobile_workspace = mobile; + if !mobile { + self.reveal_workspace(&workspace_id); + } + } + + fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(surface) = self.pane_surface.as_ref() else { + return; + }; + if surface.panes.is_empty() { + return; + } + let current = snapshot + .focused_pane_id + .as_deref() + .and_then(|focused| { + surface + .panes + .iter() + .position(|pane| pane.pane_id == focused) + }) + .unwrap_or(0); + let next = if reverse { + (current + surface.panes.len() - 1) % surface.panes.len() + } else { + (current + 1) % surface.panes.len() + }; + let pane_id = surface.panes[next].pane_id.clone(); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + + fn route_resize_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + let resize_bindings = &self.config.keybinds.keybinds.resize_mode; + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || resize_bindings.matches_prefix_key(key) + || resize_bindings.matches_direct_key(key) + { + self.mode = self.copy_or_terminal_mode(); + outcome.repaint = true; + return; + } + + let action = match key.code { + KeyCode::Char('h') | KeyCode::Left => Some(crate::input::KeybindAction::ResizePaneLeft), + KeyCode::Char('j') | KeyCode::Down => Some(crate::input::KeybindAction::ResizePaneDown), + KeyCode::Char('k') | KeyCode::Up => Some(crate::input::KeybindAction::ResizePaneUp), + KeyCode::Char('l') | KeyCode::Right => { + Some(crate::input::KeybindAction::ResizePaneRight) + } + _ => None, + }; + if let Some(action) = action { + self.record_binding(crate::input::KeybindMatch::Action(action), outcome); + } + } + + fn input_context(&self) -> ClientInputContext { + ClientInputContext { + mode: self.mode, + overlay: self.overlay.as_ref().map(ClientShellOverlay::kind), + popup_terminal_id: self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }), + popup_pending: self.popup_pending, + retained_selection: self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible), + } + } + + pub(super) fn focused_pane_id(&self) -> Option { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.clone()) + } + + pub(crate) fn clipboard_image_target( + &self, + ) -> Option { + if matches!( + self.overlay, + Some( + ClientShellOverlay::Onboarding + | ClientShellOverlay::ProductAnnouncement(_) + | ClientShellOverlay::ReleaseNotes(_) + ) + ) || self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search_prompt.is_some()) + { + return None; + } + if let Some(terminal_id) = self.popup_input_target().and_then(|target| match target { + ClientInputTarget::Popup(terminal_id) => Some(terminal_id), + ClientInputTarget::Pane(_) => None, + }) { + return Some(crate::protocol::ClientClipboardImageTarget::Popup( + terminal_id, + )); + } + if self.popup_pending || self.overlay.is_some() || self.mode != ClientShellMode::Terminal { + return None; + } + self.focused_pane_id() + .map(crate::protocol::ClientClipboardImageTarget::Pane) + } + + fn popup_input_target(&self) -> Option { + self.popup_terminal_id + .as_ref() + .map(|terminal_id| ClientInputTarget::Popup(terminal_id.clone())) + } + + fn push_pane_key( + &self, + target: ClientInputTarget, + key: crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + if let Some(event) = ClientPaneInputEvent::from_terminal_key(key) { + super::push_target_event(target, event, outcome); + } + } + + fn push_focused_pane_event(&self, event: ClientPaneInputEvent, outcome: &mut ClientShellInput) { + if let Some(pane_id) = self.focused_pane_id() { + super::push_target_event(ClientInputTarget::Pane(pane_id), event, outcome); + } + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs new file mode 100644 index 00000000..014f51a0 --- /dev/null +++ b/src/client/shell/mobile.rs @@ -0,0 +1,999 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Widget}, +}; + +use super::render::{display_width, put_segment, put_text}; +use super::*; + +const MOBILE_BUTTON_WIDTH: u16 = 10; + +struct MobileItem { + lines: Vec>, + background: Color, + target: Option, +} + +impl MobileItem { + fn section(label: impl Into, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + format!(" {} ", label.into()), + Style::default() + .fg(palette.overlay1) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ))], + background: palette.panel_bg, + target: None, + } + } + + fn action(label: &'static str, target: ClientMobileTarget, palette: &Palette) -> Self { + Self { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ))], + background: palette.panel_bg, + target: Some(target), + } + } +} + +pub(super) fn render_mobile_header( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let button_width = MOBILE_BUTTON_WIDTH.min(area.width); + let button = Rect::new( + area.right().saturating_sub(button_width), + area.y, + button_width, + area.height, + ); + hits.mobile_switch = button; + let status_width = button.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_width, area.height); + render_header_status(buffer, status, snapshot, config); + render_header_button(buffer, button, snapshot, config); +} + +fn render_header_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + let Some(workspace) = snapshot.focused_workspace_id.as_deref().and_then(|id| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == id) + }) else { + put_text( + buffer, + area.x, + area.y, + area.width, + " no workspace", + Style::default().fg(palette.text).bg(palette.panel_bg), + ); + return; + }; + let tab_status = compact_tab_status(snapshot, workspace); + let tab_width = display_width(&tab_status).saturating_add(1).min(area.width); + let name_width = area.width.saturating_sub(tab_width); + put_text( + buffer, + area.x, + area.y, + name_width.min(3), + &format!( + " {} ", + status_icon(workspace.agent_status, config.status_indicators) + ), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(palette.panel_bg), + ); + put_text( + buffer, + area.x.saturating_add(3), + area.y, + name_width.saturating_sub(3), + &crate::ui::truncate_end(&workspace.label, usize::from(name_width.saturating_sub(4))), + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.right().saturating_sub(tab_width).saturating_add(1), + area.y, + tab_width.saturating_sub(1), + &tab_status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if area.height > 1 { + render_agent_summary( + buffer, + Rect::new(area.x, area.y + 1, area.width, 1), + snapshot, + config, + ); + } +} + +fn render_header_button( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + let label = "switch"; + let label_width = display_width(label); + put_text( + buffer, + area.x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2), + label_y, + area.width.saturating_sub(1), + label, + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if snapshot + .agents + .iter() + .any(|agent| agent.agent_status == crate::api::schema::AgentStatus::Blocked) + { + put_text( + buffer, + area.right().saturating_sub(1), + area.y, + 1, + status_icon( + crate::api::schema::AgentStatus::Blocked, + config.status_indicators, + ), + Style::default().fg(palette.red).bg(palette.surface0), + ); + } +} + +fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .collect::>(); + let active = tabs + .iter() + .position(|tab| tab.tab_id == workspace.active_tab_id) + .unwrap_or(0); + let label = tabs + .get(active) + .map(|tab| tab.label.as_str()) + .unwrap_or("1"); + if tabs.len() <= 1 { + format!("tab {label}") + } else { + format!("tab {label} · {}/{}", active + 1, tabs.len()) + } +} + +fn render_agent_summary( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, +) { + use crate::api::schema::AgentStatus; + let counts = [ + (AgentStatus::Blocked, "blocked"), + (AgentStatus::Done, "done"), + (AgentStatus::Working, "working"), + (AgentStatus::Idle, "idle"), + ] + .map(|(status, label)| { + ( + status, + label, + snapshot + .agents + .iter() + .filter(|agent| agent.agent_status == status) + .count(), + ) + }); + let total = counts.iter().map(|(_, _, count)| count).sum::(); + let pending = counts[..3].iter().map(|(_, _, count)| count).sum::(); + if total == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " no agents", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + if pending == 0 { + put_text( + buffer, + area.x, + area.y, + area.width, + " all idle", + Style::default() + .fg(config.palette.overlay1) + .bg(config.palette.panel_bg), + ); + return; + } + + let mut x = area.x.saturating_add(1); + let mut shown = 0usize; + let mut omitted = false; + for (status, label, count) in counts { + if count == 0 { + continue; + } + let symbol = match (config.status_indicators, status) { + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Blocked) => Some("◉"), + (crate::config::StatusIndicatorStyle::Dots, AgentStatus::Done) => Some("●"), + (crate::config::StatusIndicatorStyle::Dots, _) => None, + _ => Some(status_icon(status, config.status_indicators)), + }; + let text = symbol.map_or_else( + || format!("{count} {label}"), + |symbol| format!("{symbol} {count} {label}"), + ); + let separator = if shown == 0 { "" } else { " · " }; + let needed = display_width(separator).saturating_add(display_width(&text)); + if area.right().saturating_sub(x) < needed { + omitted = true; + break; + } + if !separator.is_empty() { + x = put_segment( + buffer, + x, + area.y, + area.right(), + separator, + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } + let color = if shown == 0 { + match status { + AgentStatus::Done => config.palette.blue, + _ => status_color(status, &config.palette), + } + } else { + config.palette.overlay1 + }; + x = put_segment( + buffer, + x, + area.y, + area.right(), + &text, + Style::default() + .fg(color) + .bg(config.palette.panel_bg) + .add_modifier(if shown == 0 { + Modifier::BOLD + } else { + Modifier::empty() + }), + ); + shown += 1; + } + if omitted && area.right().saturating_sub(x) >= 2 { + put_text( + buffer, + x, + area.y, + 2, + " …", + Style::default() + .fg(config.palette.overlay0) + .bg(config.palette.panel_bg), + ); + } +} + +pub(super) fn render_mobile_switcher( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + scroll: &mut usize, + reveal_workspace: &mut bool, + hits: &mut ShellHitMap, +) { + if area.is_empty() { + return; + } + let palette = &config.palette; + Clear.render(area, buffer); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + hits.mobile_switch = Rect::default(); + if area.height <= 2 { + *scroll = 0; + put_text( + buffer, + area.x, + area.y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + return; + } + let header_height = area.height.min(2); + let close_width = MOBILE_BUTTON_WIDTH.min(area.width); + let close = Rect::new( + area.right().saturating_sub(close_width), + area.y, + close_width, + header_height, + ); + hits.mobile_close = close; + put_text( + buffer, + area.x, + area.y, + close.x.saturating_sub(area.x), + " switch", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + render_close_button(buffer, close, palette); + let rule_y = area.y + header_height; + put_text( + buffer, + area.x, + rule_y, + area.width, + &"─".repeat(usize::from(area.width)), + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + let viewport = Rect::new( + area.x, + rule_y.saturating_add(1), + area.width, + area.height.saturating_sub(header_height + 1), + ); + if viewport.is_empty() { + *scroll = 0; + return; + } + + let items = mobile_items( + snapshot, + config, + selected_workspace_id, + viewport.width.saturating_sub(1), + ); + let total_rows = items.iter().map(|item| item.lines.len()).sum::(); + let max_scroll = total_rows.saturating_sub(usize::from(viewport.height)); + *scroll = (*scroll).min(max_scroll); + if *reveal_workspace { + if let Some(selected_workspace_id) = selected_workspace_id { + let mut start = 0usize; + for item in &items { + let end = start.saturating_add(item.lines.len()); + if matches!( + item.target.as_ref(), + Some(ClientMobileTarget::Workspace(workspace_id)) + if workspace_id == selected_workspace_id + ) { + if start < *scroll { + *scroll = start; + } else if end > (*scroll).saturating_add(usize::from(viewport.height)) { + *scroll = end + .saturating_sub(usize::from(viewport.height)) + .min(max_scroll); + } + break; + } + start = end; + } + } + *reveal_workspace = false; + } + hits.mobile_max_scroll = max_scroll; + let content = if viewport.width > 1 { + Rect::new( + viewport.x + 1, + viewport.y, + viewport.width - 1, + viewport.height, + ) + } else { + Rect::default() + }; + if max_scroll > 0 { + render_left_scrollbar(buffer, viewport, total_rows, *scroll, palette); + } + if content.is_empty() { + return; + } + + let viewport_start = *scroll; + let viewport_end = viewport_start.saturating_add(usize::from(viewport.height)); + let mut document_row = 0usize; + for item in items { + let item_start = document_row; + let item_end = item_start.saturating_add(item.lines.len()); + let visible_start = item_start.max(viewport_start); + let visible_end = item_end.min(viewport_end); + if visible_start < visible_end { + let y = viewport.y + u16::try_from(visible_start - viewport_start).unwrap_or(u16::MAX); + let height = u16::try_from(visible_end - visible_start).unwrap_or(u16::MAX); + let rect = Rect::new(content.x, y, content.width, height); + buffer.set_style(rect, Style::default().bg(item.background)); + for row in visible_start..visible_end { + let line = item.lines[row - item_start].clone(); + Paragraph::new(line).render( + Rect::new( + content.x, + viewport.y + u16::try_from(row - viewport_start).unwrap_or(u16::MAX), + content.width, + 1, + ), + buffer, + ); + } + if let Some(target) = item.target { + hits.mobile_targets.push((rect, target)); + } + } + document_row = item_end; + } +} + +fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { + if area.is_empty() { + return; + } + buffer.set_style(area, Style::default().bg(palette.surface0)); + for y in area.y..area.bottom() { + put_text( + buffer, + area.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.surface0), + ); + } + let label_width = 5; + let label_x = area + .x + .saturating_add(1) + .saturating_add(area.width.saturating_sub(1 + label_width) / 2); + put_text( + buffer, + label_x, + area.y, + area.width.saturating_sub(1), + "close", + Style::default() + .fg(palette.overlay1) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + if area.height > 1 { + put_text( + buffer, + area.x.saturating_add(area.width / 2), + area.y + 1, + 1, + "×", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + } +} + +fn mobile_items( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + content_width: u16, +) -> Vec { + let palette = &config.palette; + let mut items = Vec::new(); + let ordered_agents = + super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); + if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { + let title = snapshot + .agent_view_label + .as_deref() + .map(|label| format!("agents · {label}")) + .unwrap_or_else(|| "agents".to_owned()); + items.push(MobileItem::section(title, palette)); + if ordered_agents.is_empty() { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + " no matching agents", + Style::default() + .fg(palette.overlay0) + .bg(palette.panel_bg) + .add_modifier(Modifier::DIM), + ))], + background: palette.panel_bg, + target: None, + }); + } + for pane_id in ordered_agents { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + 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 agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .unwrap_or("agent"); + let primary = workspace + .map(|workspace| workspace.label.as_str()) + .unwrap_or(agent_label); + let mut detail = Vec::new(); + let workspace_tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + if let Some(tab) = tab.filter(|tab| tab.custom_label || workspace_tab_count > 1) { + detail.push(tab.label.clone()); + } + let status_key = status_text(agent.agent_status); + detail.push( + agent + .state_labels + .iter() + .find(|(key, _)| key == status_key) + .map(|(_, label)| label.clone()) + .unwrap_or_else(|| { + if agent.agent_status == crate::api::schema::AgentStatus::Unknown { + "idle".to_owned() + } else { + status_key.to_owned() + } + }), + ); + detail.push(agent_label.to_owned()); + let background = if agent.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled( + status_icon(agent.agent_status, config.status_indicators), + Style::default() + .fg(status_color(agent.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + primary, + usize::from(content_width.saturating_sub(5)), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!(" {}", detail.join(" · ")), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + }); + } + } + + items.push(MobileItem::section("spaces", palette)); + items.push(MobileItem::action( + " + new workspace", + ClientMobileTarget::NewWorkspace, + palette, + )); + for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if workspace.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } + } else { + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default().fg(palette.overlay0).bg(background), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(status_color(workspace.agent_status, palette)) + .bg(background), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + name, + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + crate::ui::truncate_end( + &format!( + "{detail_prefix}{branch} · {}", + compact_tab_status(snapshot, workspace) + ), + usize::from(content_width), + ), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace( + workspace.workspace_id.clone(), + )), + }); + } + + if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { + items.push(MobileItem::section("tabs", palette)); + items.push(MobileItem::action( + " + new tab", + ClientMobileTarget::NewTab, + palette, + )); + for (index, tab) in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .enumerate() + { + let background = if tab.focused { + palette.surface_dim + } else { + palette.panel_bg + }; + let label = if tab.custom_label { + format!("{} · {}", index + 1, tab.label) + } else { + format!("tab {}", tab.label) + }; + let label = format!( + " {}", + crate::ui::truncate_end(&label, usize::from(content_width.saturating_sub(3)),) + ); + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + label, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ))], + background, + target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + }); + } + } + + items.push(MobileItem::section("menu", palette)); + for (index, (label, _)) in super::global_menu::global_menu_items(snapshot) + .into_iter() + .enumerate() + { + items.push(MobileItem { + lines: vec![Line::from(Span::styled( + format!(" {label}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ))], + background: palette.panel_bg, + target: Some(ClientMobileTarget::Menu(index)), + }); + } + items +} + +impl ClientShellState { + pub(super) fn handle_mobile_mouse( + &mut self, + mouse: crossterm::event::MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + let mobile = self + .last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()); + if !mobile || self.overlay.is_some() { + return false; + } + use crossterm::event::{MouseButton, MouseEventKind}; + let point = (mouse.column, mouse.row); + if self.mode != ClientShellMode::Navigate { + if matches!( + self.mode, + ClientShellMode::Terminal | ClientShellMode::Resize + ) && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.mobile_switch, point) + { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return true; + } + return false; + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + self.mobile_switcher_scroll = self.mobile_switcher_scroll.saturating_sub(2); + outcome.repaint = true; + return true; + } + MouseEventKind::ScrollDown => { + self.mobile_switcher_scroll = self + .mobile_switcher_scroll + .saturating_add(2) + .min(self.hits.mobile_max_scroll); + outcome.repaint = true; + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return true, + } + + if super::contains(self.hits.mobile_close, point) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.repaint = true; + return true; + } + let target = self + .hits + .mobile_targets + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, target)| target.clone()); + match target { + Some(ClientMobileTarget::NewWorkspace) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + outcome, + ); + } + Some( + target @ (ClientMobileTarget::Workspace(_) + | ClientMobileTarget::Tab(_) + | ClientMobileTarget::Agent(_)), + ) => { + let method = match target { + ClientMobileTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientMobileTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id, + }) + } + ClientMobileTarget::Agent(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + ClientMobileTarget::NewWorkspace + | ClientMobileTarget::NewTab + | ClientMobileTarget::Menu(_) => return true, + }; + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + self.push_endpoint_method(method, outcome); + } + Some(ClientMobileTarget::NewTab) => { + self.mobile_switcher_suspended = true; + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + } + Some(ClientMobileTarget::Menu(index)) => { + let actionable = self.snapshot.as_deref().is_some_and(|snapshot| { + super::global_menu::global_menu_items(snapshot) + .get(index) + .is_some_and(|(_, action)| { + *action != super::global_menu::ClientGlobalMenuAction::WhatsNew + || snapshot.release_notes.is_some() + }) + }); + if actionable { + self.mobile_switcher_suspended = true; + self.activate_global_menu_item(index, outcome); + } + } + None => {} + } + outcome.repaint = true; + true + } +} + +fn render_left_scrollbar( + buffer: &mut Buffer, + viewport: Rect, + total_rows: usize, + scroll: usize, + palette: &Palette, +) { + if viewport.is_empty() { + return; + } + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: total_rows + .saturating_sub(usize::from(viewport.height)) + .saturating_sub(scroll), + max_offset_from_bottom: total_rows.saturating_sub(usize::from(viewport.height)), + viewport_rows: usize::from(viewport.height), + }; + let track = Rect::new(viewport.x, viewport.y, 1, viewport.height); + for y in track.y..track.bottom() { + put_text( + buffer, + track.x, + y, + 1, + "│", + Style::default() + .fg(palette.surface_dim) + .bg(palette.panel_bg), + ); + } + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + put_text( + buffer, + track.x, + y, + 1, + "▌", + Style::default().fg(palette.accent).bg(palette.panel_bg), + ); + } + } +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs new file mode 100644 index 00000000..437ec4ed --- /dev/null +++ b/src/client/shell/mouse.rs @@ -0,0 +1,2304 @@ +use super::*; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + +const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30); + +impl ClientShellState { + fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) { + let (min, max) = crate::config::validated_sidebar_bounds( + self.config.sidebar_min_width, + self.config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let width = column.saturating_add(1).clamp(min, max); + if self.sidebar_width != width { + self.sidebar_width = width; + self.sidebar_width_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + } + } + + fn set_sidebar_section_from_row(&mut self, row: u16, outcome: &mut ClientShellInput) { + let divider = self.hits.sidebar_divider; + if divider.height == 0 { + return; + } + let ratio = row.saturating_sub(divider.y) as f32 / divider.height as f32; + let ratio = ratio.clamp(0.1, 0.9); + if (self.sidebar_section_split - ratio).abs() > f32::EPSILON { + self.sidebar_section_split = ratio; + self.sidebar_section_split_manual = true; + outcome.repaint = true; + } + } + + fn pane_scrollbar_offset( + hit: &PaneHit, + row: u16, + grab_row_offset: Option, + ) -> Option { + let track = hit.scrollbar_rect?; + let metrics = hit.scroll?; + (metrics.max_offset_from_bottom > 0).then(|| match grab_row_offset { + Some(grab_row_offset) => { + crate::ui::scrollbar_offset_from_drag_row(metrics, track, row, grab_row_offset) + } + None => crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + + pub(super) fn push_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + if self.pane_scroll_in_flight.contains_key(&pane_id) { + self.pane_scroll_queued.insert(pane_id, offset_from_bottom); + return; + } + self.dispatch_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + } + + fn dispatch_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + if self.snapshot.is_none() { + return; + } + self.next_scroll_serial = self.next_scroll_serial.saturating_add(1); + let serial = self.next_scroll_serial; + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + self.pane_scroll_in_flight.insert(pane_id.clone(), serial); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneScroll(crate::api::schema::PaneScrollParams { + pane_id: pane_id.clone(), + offset_from_bottom: offset_from_bottom as u64, + }), + PendingEndpointKind::PaneScroll { pane_id, serial }, + outcome, + ); + } + + pub(super) fn complete_pane_scroll( + &mut self, + pane_id: String, + serial: u64, + result: Result, + outcome: &mut ClientShellInput, + ) -> bool { + if self.pane_scroll_in_flight.get(&pane_id).copied() != Some(serial) { + return false; + } + self.pane_scroll_in_flight.remove(&pane_id); + let repaint = match result { + Ok(crate::api::schema::ResponseResult::PaneInfo { pane }) + if pane.pane_id == pane_id => + { + if let Some(scroll) = pane.scroll { + if self.pane_scroll_targets.contains_key(&pane_id) { + self.pane_scroll_targets.insert( + pane_id.clone(), + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX), + ); + } + } + false + } + 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()); + true + } + Err(error) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = Some(error.message); + true + } + }; + if let Some(offset) = self.pane_scroll_queued.remove(&pane_id) { + self.dispatch_pane_scroll_offset(pane_id, offset, outcome); + } + repaint + } + + pub(super) fn stop_selection_autoscroll(&mut self) { + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + fn selection_scroll_metrics(&self, hit: &PaneHit) -> Option { + let metrics = hit.scroll?; + Some( + self.selection_autoscroll + .as_ref() + .filter(|autoscroll| autoscroll.pane_id == hit.pane_id) + .map_or(metrics, |autoscroll| crate::pane::ScrollMetrics { + offset_from_bottom: autoscroll.offset_from_bottom, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: metrics.viewport_rows, + }), + ) + } + + fn update_selection_cursor_with_metrics( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + metrics: Option, + ) { + if let Some(selection) = self.selection.as_mut() { + selection.drag(column, row, hit.inner_rect, metrics); + } + } + + fn update_selection_drag( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + outcome: &mut ClientShellInput, + ) { + let metrics = self.selection_scroll_metrics(hit); + let was_dragging = self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_dragging); + let moved_from_anchor = self.selection.as_ref().is_some_and(|selection| { + let (anchor_row, anchor_col) = selection.anchor_screen_pos(hit.inner_rect, metrics); + anchor_row != row || anchor_col != column + }); + let is_dragging = was_dragging || moved_from_anchor; + self.update_selection_cursor_with_metrics(hit, column, row, metrics); + if is_dragging { + if let Some(selection) = self.selection.as_mut() { + if selection.is_just_click() { + selection.force_dragging(); + } + } + self.last_pane_click = None; + } + if !is_dragging { + self.stop_selection_autoscroll(); + return; + } + + let Some(metrics) = metrics else { + self.stop_selection_autoscroll(); + return; + }; + let top = hit.inner_rect.y; + let bottom = hit.inner_rect.y + hit.inner_rect.height.saturating_sub(1); + let (direction, immediate_lines) = if row < top { + ( + ClientSelectionAutoscrollDirection::Up, + Self::selection_edge_scroll_lines(top - row), + ) + } else if row > bottom { + ( + ClientSelectionAutoscrollDirection::Down, + Self::selection_edge_scroll_lines(row - bottom), + ) + } else if row == top { + (ClientSelectionAutoscrollDirection::Up, 0) + } else if row == bottom { + (ClientSelectionAutoscrollDirection::Down, 0) + } else { + self.stop_selection_autoscroll(); + return; + }; + + let offset_from_bottom = match direction { + ClientSelectionAutoscrollDirection::Up => metrics + .offset_from_bottom + .saturating_add(immediate_lines) + .min(metrics.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + metrics.offset_from_bottom.saturating_sub(immediate_lines) + } + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics(hit, column, row, Some(projected)); + self.push_pane_scroll_offset(hit.pane_id.clone(), offset_from_bottom, outcome); + } + self.selection_autoscroll = Some(ClientSelectionAutoscroll { + pane_id: hit.pane_id.clone(), + direction, + last_mouse_column: column, + last_mouse_row: row, + inner_rect: hit.inner_rect, + offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + }); + self.selection_autoscroll_deadline = + Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL); + } + + fn scroll_in_progress_selection( + &mut self, + mouse: MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) || !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_in_progress) + { + return false; + } + let Some(hit) = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }) else { + return false; + }; + let Some(metrics) = self.selection_scroll_metrics(&hit) else { + return false; + }; + let offset_from_bottom = match mouse.kind { + MouseEventKind::ScrollUp => metrics + .offset_from_bottom + .saturating_add(self.config.mouse_scroll_lines) + .min(metrics.max_offset_from_bottom), + MouseEventKind::ScrollDown => metrics + .offset_from_bottom + .saturating_sub(self.config.mouse_scroll_lines), + _ => unreachable!(), + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics( + &hit, + mouse.column, + mouse.row, + Some(projected), + ); + self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome); + outcome.repaint = true; + } + true + } + + pub(crate) fn tick_selection_autoscroll( + &mut self, + now: std::time::Instant, + ) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if self + .selection_autoscroll_deadline + .is_none_or(|deadline| now < deadline) + { + return outcome; + } + let Some(mut autoscroll) = self.selection_autoscroll.clone() else { + self.selection_autoscroll_deadline = None; + return outcome; + }; + if !self.selection.as_ref().is_some_and(|selection| { + selection.pane_id == autoscroll.pane_id && selection.is_dragging() + }) { + self.stop_selection_autoscroll(); + return outcome; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == autoscroll.pane_id) + .cloned() + else { + self.stop_selection_autoscroll(); + return outcome; + }; + if hit.inner_rect != autoscroll.inner_rect { + self.stop_selection_autoscroll(); + return outcome; + } + let next_offset = match autoscroll.direction { + ClientSelectionAutoscrollDirection::Up => autoscroll + .offset_from_bottom + .saturating_add(1) + .min(autoscroll.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + autoscroll.offset_from_bottom.saturating_sub(1) + } + }; + if next_offset == autoscroll.offset_from_bottom { + self.stop_selection_autoscroll(); + return outcome; + } + autoscroll.offset_from_bottom = next_offset; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: next_offset, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: hit.scroll.map_or(0, |metrics| metrics.viewport_rows), + }; + self.update_selection_cursor_with_metrics( + &hit, + autoscroll.last_mouse_column, + autoscroll.last_mouse_row, + Some(metrics), + ); + self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); + self.selection_autoscroll = Some(autoscroll); + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); + outcome.repaint = true; + outcome + } + + fn pane_split_target_is_current(&self, hit: &PaneSplitHit, tab_id: &str) -> Option { + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + Some( + snapshot.focused_tab_id.as_deref() == Some(tab_id) + && pane_surface_topology_signature(surface) == hit.topology_signature, + ) + } + + fn pane_split_ratio(hit: &PaneSplitHit, grab_offset: i32, point: (u16, u16)) -> f32 { + let (pointer, origin, length) = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + (i32::from(point.0), i32::from(hit.area.x), hit.area.width) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + (i32::from(point.1), i32::from(hit.area.y), hit.area.height) + } + }; + ((pointer + grab_offset - origin) as f32 / f32::from(length.max(1))).clamp(0.1, 0.9) + } + + fn tab_drop_index_at(&self, point: (u16, u16)) -> Option { + let snapshot = self.snapshot.as_deref()?; + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .collect::>(); + let visible = self + .hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + let on_tab_row = point.1 == first_rect.y; + if !on_tab_row { + return None; + } + if super::contains(self.hits.tab_scroll_left, point) { + return Some(0); + } + if super::contains(self.hits.tab_scroll_right, point) { + return Some(tabs.len()); + } + let left_edge = if first_index == 0 { + first_rect.x + } else { + self.hits.tab_scroll_left.right() + }; + let right_edge = if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + self.hits.tab_scroll_right.x.saturating_sub(1) + }; + if point.0 <= left_edge { + return Some(first_index); + } + if point.0 >= right_edge { + return Some(last_index + 1); + } + for (index, rect) in visible { + let midpoint = rect.x + rect.width / 2; + if point.0 < midpoint { + return Some(index); + } + if point.0 < rect.right() { + return Some(index + 1); + } + } + Some(last_index + 1) + } + + fn workspace_drop_target_at(&self, point: (u16, u16)) -> Option<(Option, u16)> { + if self.hits.workspace_body.height == 0 + || point.1 < self.hits.workspace_body.y.saturating_sub(1) + || point.1 >= self.hits.new_workspace.y + { + return None; + } + let mut slots = self + .hits + .workspaces + .iter() + .filter(|hit| !hit.indented) + .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) + .collect::>(); + let snapshot = self.snapshot.as_deref()?; + let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let last_hit = self.hits.workspaces.last()?; + let last_position = entries.iter().position(|entry| { + snapshot + .workspaces + .get(entry.index) + .is_some_and(|workspace| workspace.workspace_id == last_hit.workspace_id) + })?; + let next = entries.get(last_position + 1); + if !next.is_some_and(|entry| entry.indented) { + let before = next.and_then(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| workspace.workspace_id.clone()) + }); + let row = last_hit.rect.bottom(); + if row < self.hits.new_workspace.y { + slots.push((before, row)); + } + } + slots + .into_iter() + .enumerate() + .min_by_key(|(index, (_, row))| (point.1.abs_diff(*row), *index)) + .map(|(_, target)| target) + } + + fn workspace_move_method( + &self, + source_workspace_id: &str, + before_workspace_id: Option<&str>, + ) -> Option { + let snapshot = self.snapshot.as_deref()?; + let source = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id)?; + if source + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + { + return None; + } + if before_workspace_id == Some(source_workspace_id) { + return None; + } + let roots = snapshot + .workspaces + .iter() + .filter(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + .collect::>(); + let source_position = roots + .iter() + .position(|workspace| workspace.workspace_id == source_workspace_id)?; + let remaining = roots + .iter() + .copied() + .filter(|workspace| workspace.workspace_id != source_workspace_id) + .collect::>(); + let insert_position = match before_workspace_id { + Some(target) => remaining + .iter() + .position(|workspace| workspace.workspace_id == target)?, + None => remaining.len(), + }; + if insert_position == source_position { + return None; + } + + if let Some(worktree) = source.worktree.as_ref() { + let workspace_ids = std::iter::once(source.workspace_id.clone()) + .chain( + snapshot + .workspaces + .iter() + .filter(|workspace| workspace.workspace_id != source.workspace_id) + .filter(|workspace| { + workspace + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|workspace| workspace.workspace_id.clone()), + ) + .collect(); + Some(crate::api::schema::Method::WorkspaceMoveBlock( + crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id: before_workspace_id.map(str::to_owned), + }, + )) + } else { + let insert_index = before_workspace_id + .and_then(|target| { + snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == target) + }) + .unwrap_or(snapshot.workspaces.len()); + Some(crate::api::schema::Method::WorkspaceMove( + crate::api::schema::WorkspaceMoveParams { + workspace_id: source.workspace_id.clone(), + insert_index, + }, + )) + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + let point = (mouse.column, mouse.row); + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.overlay_primary, point) + { + self.complete_onboarding(outcome); + } + return; + } + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.overlay_primary, point) => + { + self.dismiss_product_announcement(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.product_announcement_scrollbar, point) => + { + if let Some(metrics) = self.hits.product_announcement_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::ProductAnnouncementScrollbar { + grab_row_offset, + }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset }), + Some(metrics), + ) = ( + self.chrome_drag.as_ref(), + self.hits.product_announcement_scroll_metrics, + ) { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_product_announcement(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_product_announcement(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + let (close, track, metrics) = self + .current_release_notes_input_geometry() + .map(|(close, track, metrics)| (close, track, Some(metrics))) + .unwrap_or(( + self.hits.overlay_primary, + (!self.hits.release_notes_scrollbar.is_empty()) + .then_some(self.hits.release_notes_scrollbar), + self.hits.release_notes_scroll_metrics, + )); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if super::contains(close, point) => { + self.dismiss_release_notes(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if track.is_some_and(|track| super::contains(track, point)) => + { + if let (Some(track), Some(metrics)) = (track, metrics) { + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }); + } else { + let offset = + crate::ui::scrollbar_offset_from_row(metrics, track, mouse.row); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }), + Some(track), + Some(metrics), + ) = (self.chrome_drag.as_ref(), track, metrics) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + mouse.row, + *grab_row_offset, + ); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_release_notes(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_release_notes(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.url_click_consumes_until_up { + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) => return, + MouseEventKind::Up(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + return; + } + MouseEventKind::Down(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + } + _ => {} + } + } + if !self.replaying_url_click + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) + { + if let Some(fallback_events) = + self.pending_requests + .values_mut() + .find_map(|pending| match &mut pending.kind { + PendingEndpointKind::PaneLinkActivate { + fallback_events, .. + } if !fallback_events + .iter() + .any(|event| event.kind == MouseEventKind::Up(MouseButton::Left)) => + { + Some(fallback_events) + } + _ => None, + }) + { + fallback_events.push(mouse); + return; + } + } + if let Some(gesture) = self.pane_mouse_gesture.as_ref() { + let gesture_event = matches!( + mouse.kind, + MouseEventKind::Drag(button) | MouseEventKind::Up(button) + if button == gesture.button + ); + if gesture_event { + let button = gesture.button; + let modifiers = mouse.modifiers.difference(gesture.stripped_modifiers); + let hit = if gesture.hit.popup { + self.hits + .popup + .as_ref() + .filter(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } else { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } + .unwrap_or_else(|| gesture.hit.clone()); + let position = self.pane_mouse_position(&hit, mouse); + if let Some(gesture) = self.pane_mouse_gesture.as_mut() { + gesture.last_event = mouse; + gesture.last_position = position; + } + self.push_pane_mouse_event(&hit, mouse, modifiers, outcome); + if mouse.kind == MouseEventKind::Up(button) { + self.pane_mouse_gesture = None; + } + return; + } + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::Drag(_) | MouseEventKind::Up(_) + ) { + return; + } + } + if self.popup_pending { + return; + } + if let Some(hit) = self.hits.popup.clone() { + if super::contains(hit.inner_rect, point) { + match mouse.kind { + MouseEventKind::Down(button) => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + if hit.mouse_reporting { + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Moved if hit.mouse_reporting => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => {} + } + } + return; + } + if self.popup_terminal_id.is_some() { + return; + } + if !self.replaying_url_click + && self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && mouse + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + let viewport_row = mouse.row.saturating_sub(hit.inner_rect.y); + let col = mouse.column.saturating_sub(hit.inner_rect.x); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.last_pane_click = None; + let pane_id = hit.pane_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row, + col, + content_revision, + offset_from_bottom: hit + .scroll + .map(|metrics| metrics.offset_from_bottom as u64), + }, + ), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect: hit.inner_rect, + fallback_events: vec![mouse], + }, + outcome, + ); + return; + } + } + if self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && self + .visible_notification + .as_ref() + .is_some_and(|notification| notification.event.pane_id.is_some()) + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.notification_toast, point) + { + self.focus_visible_notification(outcome); + return; + } + if self.handle_mobile_mouse(mouse, outcome) { + return; + } + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + match self.chrome_drag.as_ref() { + Some(ClientChromeDrag::SidebarWidth) => { + self.set_sidebar_width_from_column(mouse.column, outcome); + return; + } + Some(ClientChromeDrag::SidebarSection) => { + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.agent_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { + if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = + (self.hits.help_scroll_metrics, self.overlay.as_mut()) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + return; + } + Some( + ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. }, + ) => { + self.chrome_drag = None; + return; + } + Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + last_sent_at, + }) => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or_else(|| hit.clone()); + let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(*grab_row_offset), + ) else { + self.chrome_drag = None; + return; + }; + let now = std::time::Instant::now(); + let should_send = *last_sent_offset != Some(offset) + && last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if should_send { + if let Some(ClientChromeDrag::PaneScrollbar { + last_sent_offset, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + *last_sent_offset = Some(offset); + *last_sent_at = Some(now); + } + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + return; + } + Some(ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_at, + .. + }) => { + let hit = hit.clone(); + let tab_id = tab_id.clone(); + let grab_offset = *grab_offset; + match self.pane_split_target_is_current(&hit, &tab_id) { + Some(true) => {} + Some(false) => { + self.chrome_drag = None; + return; + } + None => return, + } + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + let now = std::time::Instant::now(); + let should_send = last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if let Some(ClientChromeDrag::PaneSplit { + last_sent_ratio, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + if should_send { + *last_sent_ratio = Some(ratio); + *last_sent_at = Some(now); + } + } + if should_send { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + return; + } + Some(ClientChromeDrag::Tab { .. }) => { + let insert_index = self.tab_drop_index_at(point); + if let Some(ClientChromeDrag::Tab { + insert_index: current, + .. + }) = self.chrome_drag.as_mut() + { + *current = insert_index; + } + outcome.repaint = true; + return; + } + Some(ClientChromeDrag::Workspace { .. }) => { + let target = self.workspace_drop_target_at(point); + if let Some(ClientChromeDrag::Workspace { + target: current, .. + }) = self.chrome_drag.as_mut() + { + *current = target; + } + outcome.repaint = true; + return; + } + None => {} + } + if let Some(press) = self.workspace_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + let source_workspace_id = press.workspace_id.clone(); + let draggable = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }); + if draggable { + if let Some(target) = self.workspace_drop_target_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Workspace { + source_workspace_id, + target: Some(target), + }); + outcome.repaint = true; + } + } + } + return; + } + if let Some(press) = self.tab_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + if let Some(insert_index) = self.tab_drop_index_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Tab { + tab_id: press.tab_id.clone(), + workspace_id: press.workspace_id.clone(), + insert_index: Some(insert_index), + }); + outcome.repaint = true; + } + } + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + if let Some(drag) = self.chrome_drag.take() { + self.workspace_press = None; + self.tab_press = None; + match drag { + ClientChromeDrag::Tab { + tab_id, + workspace_id, + .. + } => { + let insert_index = self.tab_drop_index_at(point); + let valid_drop = self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id.as_str()) + && snapshot.tabs.iter().any(|tab| { + tab.tab_id == tab_id && tab.workspace_id == workspace_id + }) + && insert_index.is_some_and(|index| { + index + <= snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + }); + if valid_drop { + self.push_endpoint_method( + crate::api::schema::Method::TabMove( + crate::api::schema::TabMoveParams { + tab_id, + insert_index: insert_index.unwrap_or_default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + } + ClientChromeDrag::Workspace { + source_workspace_id, + target, + } => { + if let Some((before_workspace_id, _)) = target { + if let Some(method) = self.workspace_move_method( + &source_workspace_id, + before_workspace_id.as_deref(), + ) { + self.push_endpoint_method(method, outcome); + } + } + outcome.repaint = true; + } + ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + .. + } => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or(hit); + if let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(grab_row_offset), + ) { + if last_sent_offset != Some(offset) { + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + } + } + ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_ratio, + .. + } => { + let target_is_current = + self.pane_split_target_is_current(&hit, &tab_id) == Some(true); + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + if target_is_current + && last_sent_ratio + .is_none_or(|sent| (sent - ratio).abs() > f32::EPSILON) + { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + } + ClientChromeDrag::SidebarWidth | ClientChromeDrag::SidebarSection => { + self.persist_chrome_preferences(outcome); + } + ClientChromeDrag::WorkspaceScrollbar { .. } + | ClientChromeDrag::AgentScrollbar { .. } + | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} + } + return; + } + if let Some(press) = self.workspace_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }, + ), + outcome, + ); + return; + } + if let Some(press) = self.tab_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: press.tab_id, + }), + outcome, + ); + return; + } + } + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + let row_hit = self + .hits + .global_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::GlobalMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + } else if let Some((_, index)) = row_hit { + self.activate_global_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + let row_hit = self + .hits + .context_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::ContextMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((_, index)) = row_hit { + self.activate_context_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate(_) + | ClientShellOverlay::WorktreeOpen(_) + | ClientShellOverlay::WorktreeRemove(_) + ) + ) { + match mouse.kind { + MouseEventKind::ScrollUp + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + MouseEventKind::ScrollDown + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.overlay_cancel, point) { + let busy = + matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + ) | ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + ) | ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + ) + ) + ); + if !busy { + self.overlay = None; + outcome.repaint = true; + } + } else if super::contains(self.hits.worktree_search, point) { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + outcome.repaint = true; + } + } else if let Some((_, index)) = self + .hits + .worktree_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.selected = index; + } + self.submit_worktree_open(outcome); + } else if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + self.submit_worktree_create(outcome) + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + self.submit_worktree_open(outcome) + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + self.submit_worktree_remove(outcome) + } + _ => {} + } + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, section)) = self + .hits + .settings_tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_section(section, outcome); + } else if let Some((_, index)) = self + .hits + .settings_choices + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_choice(index); + let immediate = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Indicators + | ClientSettingsSection::Sound + | ClientSettingsSection::Toast, + .. + })) + ); + if immediate { + self.apply_settings_choice(outcome); + } + outcome.repaint = true; + } else if super::contains(self.hits.overlay_primary, point) { + self.apply_settings_choice(outcome); + } else if super::contains(self.hits.overlay_cancel, point) + || !super::contains(self.hits.settings_popup, point) + { + let installing = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ); + if !installing { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + } + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_sub(3); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::ScrollDown => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_add(3).min(self.hits.help_max_scroll); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.help_scrollbar, point) { + if let Some(metrics) = self.hits.help_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.help_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + ); + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() + { + help.scroll = + metrics.max_offset_from_bottom.saturating_sub(offset); + outcome.repaint = true; + } + } + } + } else if super::contains(self.hits.overlay_cancel, point) { + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + } else if !super::contains(self.hits.help_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let row_hit = self + .hits + .navigator_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let Some((_, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.navigator_search, point) { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + } else if let Some((rect, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + let workspace = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + render::client_navigator_rows(snapshot, navigator) + .get(index) + .map(|row| { + matches!( + row.target, + ClientNavigatorTarget::Workspace(_) + ) + }) + } + _ => None, + }) + .unwrap_or(false); + if workspace && mouse.column <= rect.x.saturating_add(3) { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + } else { + self.accept_navigator_selection(outcome); + } + } else if !super::contains(self.hits.navigator_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp => { + self.move_navigator_selection(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.move_navigator_selection(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.overlay.is_some() { + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), + Some(ClientShellOverlay::ConfirmClose(_)) => { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() + else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } + _ => {} + } + } else if super::contains(self.hits.overlay_clear, point) { + if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + } + } else { + self.overlay = None; + outcome.repaint = true; + } + return; + } + + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + let selection_hit = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }); + if let Some(hit) = selection_hit { + self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); + outcome.repaint = true; + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) && self.selection.is_some() { + self.stop_selection_autoscroll(); + let copied = self + .selection + .as_mut() + .is_some_and(crate::selection::Selection::finish); + if copied && self.config.copy_on_select { + self.request_selection_copy(outcome); + self.selection = None; + } else if !copied { + self.selection = None; + } + if copied { + self.last_pane_click = None; + } + outcome.repaint = true; + return; + } + if self.scroll_in_progress_selection(mouse, outcome) { + return; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Right) => { + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + let pane_owns_right_click = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough) + && mouse.modifiers.is_empty(); + let configured_modifiers = self + .config + .right_click_passthrough_modifiers + .filter(|modifiers| *modifiers == mouse.modifiers); + if hit.mouse_reporting + && (pane_owns_right_click || configured_modifiers.is_some()) + { + let stripped_modifiers = + configured_modifiers.unwrap_or(crossterm::event::KeyModifiers::empty()); + self.push_pane_mouse_event( + &hit, + mouse, + mouse.modifiers.difference(stripped_modifiers), + outcome, + ); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Right, + stripped_modifiers, + last_event: mouse, + }); + return; + } + } + if !self.config.mouse_capture { + return; + } + let workspace_id = (!self.sidebar_collapsed) + .then(|| { + self.hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.workspace_id.clone()) + }) + .flatten(); + if let Some(workspace_id) = workspace_id { + self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let tab_id = self + .hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, tab_id)| tab_id.clone()); + if let Some(tab_id) = tab_id { + self.open_tab_context_menu(tab_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let pane_id = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.pane_id.clone()); + if let Some(pane_id) = pane_id { + self.open_pane_context_menu(pane_id, mouse.column, mouse.row); + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousTab), + outcome, + ); + } + MouseEventKind::ScrollDown + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextTab), + outcome, + ); + } + MouseEventKind::ScrollUp if super::contains(self.hits.agent_body, point) => { + let next = self.agent_scroll.saturating_sub(1); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.agent_body, point) => { + let next = self + .agent_scroll + .saturating_add(1) + .min(self.hits.agent_max_scroll); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp if super::contains(self.hits.workspace_body, point) => { + let next = self.workspace_scroll.saturating_sub(1); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.workspace_body, point) => { + let next = self + .workspace_scroll + .saturating_add(1) + .min(self.hits.workspace_max_scroll); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self.selection.take().is_some() { + outcome.repaint = true; + } + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + let previous_pane_click = self.last_pane_click.take(); + self.workspace_press = None; + self.tab_press = None; + self.chrome_drag = None; + if super::contains(self.hits.sidebar_divider, point) + && !super::contains(self.hits.sidebar_toggle, point) + { + let now = std::time::Instant::now(); + let double_click = self.last_sidebar_divider_click.is_some_and(|last| { + now.duration_since(last) <= std::time::Duration::from_millis(350) + }); + self.last_sidebar_divider_click = Some(now); + if double_click { + self.sidebar_width = self.config.sidebar_width; + self.sidebar_width_manual = false; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } else { + self.chrome_drag = Some(ClientChromeDrag::SidebarWidth); + self.set_sidebar_width_from_column(mouse.column, outcome); + } + return; + } + if super::contains(self.hits.sidebar_section_divider, point) { + self.chrome_drag = Some(ClientChromeDrag::SidebarSection); + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + if super::contains(self.hits.workspace_scrollbar, point) { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_scrollbar, point) { + if let Some(metrics) = self.hits.agent_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_sort_toggle, point) { + let sort = match self.config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => { + crate::config::AgentPanelSortConfig::Priority + } + crate::config::AgentPanelSortConfig::Priority => { + crate::config::AgentPanelSortConfig::Spaces + } + }; + self.config.agent_panel_sort = sort; + self.agent_panel_sort_manual = true; + self.agent_scroll = 0; + self.persist_chrome_preferences(outcome); + outcome.repaint = true; + return; + } + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + return; + } + if super::contains(self.hits.new_workspace, point) { + self.record_binding( + crate::input::KeybindMatch::Action( + crate::input::KeybindAction::NewWorkspace, + ), + outcome, + ); + return; + } + if super::contains(self.hits.new_tab, point) { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + return; + } + if super::contains(self.hits.tab_scroll_left, point) { + self.tab_scroll = self.tab_scroll.saturating_sub(1); + outcome.repaint = true; + return; + } + if super::contains(self.hits.tab_scroll_right, point) { + let tab_count = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot.focused_workspace_id.as_deref().map(|id| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == id) + .count() + }) + }) + .unwrap_or(0); + self.tab_scroll = self + .tab_scroll + .saturating_add(1) + .min(tab_count.saturating_sub(1)); + outcome.repaint = true; + return; + } + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + for hit in &self.hits.workspaces { + if let Some((rect, key)) = &hit.group_toggle { + if super::contains(*rect, point) { + if !self.collapsed_groups.remove(key) { + self.collapsed_groups.insert(key.clone()); + } + outcome.repaint = true; + self.persist_chrome_preferences(outcome); + return; + } + } + } + let workspace_press = self + .hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| ClientWorkspacePress { + workspace_id: hit.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }); + if let Some(workspace_press) = workspace_press { + self.workspace_press = Some(workspace_press); + return; + } + let tab_press = self + .config + .mouse_capture + .then(|| { + self.hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .and_then(|(_, tab_id)| { + let tab = self + .snapshot + .as_deref()? + .tabs + .iter() + .find(|tab| tab.tab_id == *tab_id)?; + Some(ClientTabPress { + tab_id: tab.tab_id.clone(), + workspace_id: tab.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }) + }) + }) + .flatten(); + if let Some(tab_press) = tab_press { + self.tab_press = Some(tab_press); + return; + } + let agent_pane_id = self + .hits + .agents + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, pane_id)| pane_id.clone()); + if let Some(pane_id) = agent_pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }), + outcome, + ); + return; + } + let scrollbar_hit = self + .hits + .panes + .iter() + .find(|hit| { + hit.scrollbar_rect + .is_some_and(|rect| super::contains(rect, point)) + && hit + .scroll + .is_some_and(|metrics| metrics.max_offset_from_bottom > 0) + }) + .cloned(); + if let Some(hit) = scrollbar_hit { + self.mode = ClientShellMode::Terminal; + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + let (Some(track), Some(metrics)) = (hit.scrollbar_rect, hit.scroll) else { + return; + }; + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset: None, + last_sent_at: None, + }); + } else if let Some(offset) = Self::pane_scrollbar_offset(&hit, mouse.row, None) + { + self.push_pane_scroll_offset(hit.pane_id, offset, outcome); + } + return; + } + let split_hit = self + .hits + .pane_splits + .iter() + .find(|hit| super::contains(hit.hit_rect, point)) + .cloned(); + if let Some(hit) = split_hit { + let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + else { + return; + }; + let pointer = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => mouse.column, + crate::protocol::PaneSurfaceSplitDirection::Vertical => mouse.row, + }; + self.chrome_drag = Some(ClientChromeDrag::PaneSplit { + grab_offset: i32::from(hit.pos) - i32::from(pointer), + last_sent_ratio: None, + last_sent_at: None, + hit, + tab_id, + }); + return; + } + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + if hit.mouse_reporting && super::contains(hit.inner_rect, point) { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit: hit.clone(), + button: MouseButton::Left, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } else if super::contains(hit.inner_rect, point) { + let click = ClientPaneClick { + pane_id: hit.pane_id.clone(), + viewport_row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + at: std::time::Instant::now(), + }; + if mouse.modifiers.is_empty() + && previous_pane_click + .as_ref() + .is_some_and(|previous| previous.is_double_click_for(&click)) + { + self.request_word_selection( + &hit, + click.viewport_row, + click.col, + outcome, + ); + } else { + if mouse.modifiers.is_empty() { + self.last_pane_click = Some(click); + } + self.selection = Some(crate::selection::Selection::anchor( + hit.pane_id.clone(), + mouse.row.saturating_sub(hit.inner_rect.y), + mouse.column.saturating_sub(hit.inner_rect.x), + hit.scroll, + )); + } + } + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id, + }), + outcome, + ); + } + } + MouseEventKind::Down(MouseButton::Middle) => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Middle, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Up(MouseButton::Left | MouseButton::Middle) + | MouseEventKind::Drag(MouseButton::Left | MouseButton::Middle) => {} + MouseEventKind::Moved => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + if self.focused_pane_id().as_deref() != Some(hit.pane_id.as_str()) { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + } + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + _ => {} + } + } + + fn pane_mouse_position(&self, hit: &PaneHit, mouse: MouseEvent) -> ClientMousePosition { + let cell = ClientMousePosition::Cell { + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }; + if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { + self.host_mouse_pixels + .and_then(|pixels| { + pixels + .pane_position(hit.inner_rect, hit.pixel_width, hit.pixel_height) + .and_then(|position| match position { + crate::input::mouse::Position::Pixels { x, y } => { + Some(ClientMousePosition::Pixels { + x, + y, + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }) + } + crate::input::mouse::Position::Cell { .. } => None, + }) + }) + .unwrap_or(cell) + } else { + cell + } + } + + pub(super) fn push_pane_mouse_event( + &self, + hit: &PaneHit, + mouse: MouseEvent, + modifiers: crossterm::event::KeyModifiers, + outcome: &mut ClientShellInput, + ) { + let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else { + return; + }; + let position = self.pane_mouse_position(hit, mouse); + let geometry = matches!(position, ClientMousePosition::Pixels { .. }).then_some( + crate::protocol::ClientMouseGeometry { + cols: hit.inner_rect.width, + rows: hit.inner_rect.height, + width_px: hit.pixel_width, + height_px: hit.pixel_height, + }, + ); + let target = if hit.popup { + ClientInputTarget::Popup(hit.pane_id.clone()) + } else { + ClientInputTarget::Pane(hit.pane_id.clone()) + }; + push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind, + position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs new file mode 100644 index 00000000..0310e57d --- /dev/null +++ b/src/client/shell/notifications.rs @@ -0,0 +1,418 @@ +use super::*; +use ratatui::{ + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +pub(super) fn render_mobile_notification_banner( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let warning_offset = u16::from(offset_for_warning); + let y = area.y + + area + .height + .saturating_sub(1u16.saturating_add(warning_offset)); + let rect = Rect::new(area.x, y, area.width, 1); + let background = palette.surface0; + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(background)); + let event = ¬ification.event; + let title = match event.kind { + SemanticNotificationKind::NeedsAttention => event + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::Finished => event + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| event.title.clone()), + SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(), + SemanticNotificationKind::Custom => event.title.clone(), + }; + let dot_color = match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }; + let mut x = rect.x; + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + "●", + Style::default().fg(dot_color).bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " ", + Style::default().bg(background), + ); + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + &title, + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ); + if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) { + x = super::render::put_segment( + buffer, + x, + rect.y, + rect.right(), + " · ", + Style::default().fg(palette.overlay0).bg(background), + ); + super::render::put_text( + buffer, + x, + rect.y, + rect.right().saturating_sub(x), + body, + Style::default().fg(palette.overlay0).bg(background), + ); + } + rect +} + +pub(super) fn render_visible_notification( + buffer: &mut Buffer, + area: Rect, + notification: &ClientVisibleNotification, + default_position: crate::config::ToastHerdrPosition, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() { + return Rect::default(); + } + let event = ¬ification.event; + let body = event.body.as_deref().unwrap_or_default(); + let content_width = unicode_width::UnicodeWidthStr::width(event.title.as_str()) + .max(unicode_width::UnicodeWidthStr::width(body)) + .saturating_add(6); + let width = u16::try_from(content_width) + .unwrap_or(u16::MAX) + .min(area.width); + let height: u16 = if body.is_empty() { 3 } else { 4 }.min(area.height); + let position = event.position.unwrap_or(default_position); + let x = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::BottomLeft => area.x, + crate::config::ToastHerdrPosition::TopRight + | crate::config::ToastHerdrPosition::BottomRight => area.right().saturating_sub(width), + }; + let max_y = area.bottom().saturating_sub(height).max(area.y); + let y = match position { + crate::config::ToastHerdrPosition::TopLeft + | crate::config::ToastHerdrPosition::TopRight => area.y.saturating_add(top_offset), + crate::config::ToastHerdrPosition::BottomLeft + | crate::config::ToastHerdrPosition::BottomRight => area + .bottom() + .saturating_sub(height.saturating_add(top_offset)), + } + .clamp(area.y, max_y); + let rect = Rect::new(x, y, width, height); + Clear.render(rect, buffer); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.overlay0)) + .style(Style::default().bg(palette.panel_bg)); + let inner = block.inner(rect); + block.render(rect, buffer); + Paragraph::new(Line::from(vec![ + Span::styled( + "●", + Style::default().fg(match event.kind { + SemanticNotificationKind::NeedsAttention => palette.red, + SemanticNotificationKind::Finished => palette.blue, + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + palette.accent + } + }), + ), + Span::raw(" "), + Span::styled( + event.title.as_str(), + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD), + ), + ])) + .render(Rect::new(inner.x, inner.y, inner.width, 1), buffer); + if !body.is_empty() && inner.height > 1 { + Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(palette.overlay0), + ))) + .render( + Rect::new( + inner.x.saturating_add(2), + inner.y + 1, + inner.width.saturating_sub(2), + 1, + ), + buffer, + ); + } + rect +} + +impl ClientShellState { + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.take() else { + return; + }; + outcome.repaint = true; + if let Some(pane_id) = notification.event.pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + } + + pub(crate) fn receive_notification( + &mut self, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification + .as_ref() + .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications + .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); + if cleared_visible { + self.visible_notification = None; + } + } + self.pending_notifications.push(ClientPendingNotification { + event, + deadline, + validate_state: delay > 0, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state && !self.notification_still_current(&pending.event) { + continue; + } + let target_active = self.notification_target_is_active(&pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + let duration = match pending.event.kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }; + self.visible_notification = Some(ClientVisibleNotification { + event: pending.event, + deadline: now + std::time::Duration::from_secs(duration), + }); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_still_current(&self, event: &SemanticNotification) -> bool { + let Some(pane_id) = event.pane_id.as_deref() else { + return true; + }; + let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) else { + return false; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention => { + agent.agent_status == crate::api::schema::AgentStatus::Blocked + } + SemanticNotificationKind::Finished => matches!( + agent.agent_status, + crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done + ), + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn notification() -> ClientVisibleNotification { + ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notice".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + deadline: std::time::Instant::now(), + } + } + + #[test] + fn mobile_notification_is_a_bottom_banner_with_released_title() { + let palette = crate::app::client_palette_from_config(&Config::default()); + let mut notification = notification(); + notification.event.kind = SemanticNotificationKind::NeedsAttention; + notification.event.title = "pi needs attention".into(); + notification.event.body = Some("workspace · tab 1".into()); + let area = Rect::new(0, 0, 44, 20); + let mut buffer = Buffer::empty(area); + for cell in &mut buffer.content { + cell.set_symbol("X"); + } + let rect = + render_mobile_notification_banner(&mut buffer, area, ¬ification, true, &palette); + assert_eq!(rect, Rect::new(0, 18, 44, 1)); + let text = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(text.contains("pi waiting")); + assert!(text.contains("workspace · tab 1")); + assert!(buffer.content[18 * 44..19 * 44] + .iter() + .all(|cell| cell.symbol() != "X")); + } + + #[test] + fn notification_rect_stays_inside_short_nonzero_area() { + let palette = crate::app::client_palette_from_config(&Config::default()); + for height in [1, 2] { + let area = Rect::new(3, 4, 8, height); + for position in [ + crate::config::ToastHerdrPosition::TopRight, + crate::config::ToastHerdrPosition::BottomRight, + ] { + let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10)); + let rect = render_visible_notification( + &mut buffer, + area, + ¬ification(), + position, + 1, + &palette, + ); + assert!(rect.y >= area.y); + assert!(rect.bottom() <= area.bottom()); + } + } + } +} diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs new file mode 100644 index 00000000..154b00b6 --- /dev/null +++ b/src/client/shell/overlay_input.rs @@ -0,0 +1,1091 @@ +use super::*; + +impl ClientShellState { + pub(super) fn dismiss_product_announcement(&mut self, outcome: &mut ClientShellInput) { + let announcement = match self.overlay.take() { + Some(ClientShellOverlay::ProductAnnouncement(announcement)) => announcement, + other => { + self.overlay = other; + return; + } + }; + if self.snapshot.is_none() { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement(announcement)); + return; + } + self.dismissed_product_announcement = + Some((announcement.version.clone(), announcement.id.clone())); + self.chrome_drag = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: announcement.version.clone(), + id: announcement.id.clone(), + }, + ), + PendingEndpointKind::ProductAnnouncementDismiss { + version: announcement.version, + id: announcement.id, + }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn scroll_product_announcement(&mut self, delta: isize) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + let current = usize::from(announcement.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + announcement.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_product_announcement_offset_from_bottom( + &mut self, + offset_from_bottom: usize, + ) { + let max_scroll = self.hits.product_announcement_max_scroll; + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = self.overlay.as_mut() { + announcement.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn open_release_notes(&mut self) { + let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + + pub(super) fn dismiss_release_notes(&mut self, outcome: &mut ClientShellInput) { + let notes = match self.overlay.take() { + Some(ClientShellOverlay::ReleaseNotes(notes)) => notes, + other => { + self.overlay = other; + return; + } + }; + self.chrome_drag = None; + self.mode = if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()) + .is_some() + { + ClientShellMode::Terminal + } else { + ClientShellMode::Navigate + }; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: notes.version.clone(), + }, + ), + PendingEndpointKind::ReleaseNotesDismiss, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn current_release_notes_input_geometry( + &self, + ) -> Option<(Rect, Option, crate::pane::ScrollMetrics)> { + let notes = match self.overlay.as_ref()? { + ClientShellOverlay::ReleaseNotes(notes) => notes, + _ => return None, + }; + let (cols, rows) = self.last_composed_size?; + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, cols, rows), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = Rect::new( + outer.x.saturating_add(1), + outer.y.saturating_add(1), + outer.width.saturating_sub(2), + outer.height.saturating_sub(2), + ); + if inner.height < 8 || inner.width < 20 { + return None; + } + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + let install_command = self + .snapshot + .as_deref() + .map(|snapshot| snapshot.update_install_command.as_str()) + .unwrap_or_default(); + let metrics = crate::ui::release_notes_scroll_metrics( + notes, + install_command, + stack.content, + &self.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics); + Some((close, track, metrics)) + } + + fn current_release_notes_max_scroll(&self) -> usize { + self.current_release_notes_input_geometry() + .map(|(_, _, metrics)| metrics.max_offset_from_bottom) + .unwrap_or(self.hits.release_notes_max_scroll) + } + + pub(super) fn scroll_release_notes(&mut self, delta: isize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + let current = usize::from(notes.scroll); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + } + .min(max_scroll); + notes.scroll = u16::try_from(next).unwrap_or(u16::MAX); + } + } + + pub(super) fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = + u16::try_from(max_scroll.saturating_sub(offset_from_bottom.min(max_scroll))) + .unwrap_or(u16::MAX); + } + } + + pub(super) fn complete_onboarding(&mut self, outcome: &mut ClientShellInput) { + if self.snapshot.is_none() { + return; + } + if let Err(error) = crate::config::update_file_at( + &self.config.local_config_path, + "onboarding setting", + |content| crate::config::upsert_top_level_bool(content, "onboarding", false), + ) { + self.set_local_config_diagnostic(Some(error)); + } + self.config.startup_onboarding = false; + self.open_settings_overlay(); + self.select_settings_section(ClientSettingsSection::Integrations, outcome); + } + + pub(super) fn open_navigator_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let expanded_workspaces = snapshot + .workspaces + .iter() + .map(|workspace| workspace.workspace_id.clone()) + .collect(); + let mut navigator = ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces, + }; + let rows = render::client_navigator_rows(snapshot, &navigator); + navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + self.overlay = Some(ClientShellOverlay::Navigator(navigator)); + } + + pub(super) fn move_navigator_selection(&mut self, delta: isize) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = render::client_navigator_rows(snapshot, navigator); + if rows.is_empty() { + navigator.selected = 0; + return; + } + navigator.selected = (navigator.selected as isize + delta) + .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + } + + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { + let target = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .map(|row| row.target.clone()) + }; + let Some(target) = target else { + return; + }; + self.overlay = None; + let method = match target { + ClientNavigatorTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientNavigatorTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientNavigatorTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + self.push_endpoint_method(method, outcome); + outcome.repaint = true; + } + + pub(super) fn toggle_selected_navigator_workspace(&mut self) { + let workspace_id = { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { + return; + }; + render::client_navigator_rows(snapshot, navigator) + .get(navigator.selected) + .and_then(|row| match &row.target { + ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), + _ => None, + }) + }; + if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_id, self.overlay.as_mut()) + { + if !navigator.expanded_workspaces.remove(&workspace_id) { + navigator.expanded_workspaces.insert(workspace_id); + } + navigator.selected = 0; + navigator.scroll = 0; + } + } + + pub(super) fn workspace_action_id(&self) -> Option { + self.navigate_workspace_id.clone().or_else(|| { + self.snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()) + }) + } + + pub(super) fn open_new_workspace_overlay(&mut self) { + let source_workspace_id = self.workspace_action_id(); + let cwd = self.snapshot.as_deref().and_then(|snapshot| { + let workspace_id = source_workspace_id.as_deref()?; + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .map(|workspace| workspace.new_workspace_cwd.clone()) + }); + let suggested_name = cwd + .as_deref() + .map(std::path::Path::new) + .map(crate::workspace::derive_label_from_cwd) + .unwrap_or_else(|| "workspace".to_owned()); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new workspace", + input: suggested_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + }, + })); + } + + pub(super) fn open_rename_workspace_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename workspace", + input: workspace.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Workspace { workspace_id }, + })); + } + + pub(super) fn open_new_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace_id) = snapshot.focused_workspace_id.clone() else { + return; + }; + let default_name = (snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + + 1) + .to_string(); + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "new tab", + input: default_name.clone(), + replace_on_type: true, + target: ClientRenameTarget::NewTab { + workspace_id, + default_name, + }, + })); + } + + pub(super) fn open_rename_tab_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(tab_id) = snapshot.focused_tab_id.as_deref() else { + return; + }; + let Some(tab) = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename tab", + input: tab.label.clone(), + replace_on_type: false, + target: ClientRenameTarget::Tab { + tab_id: tab.tab_id.clone(), + auto_name: !tab.custom_label, + original_name: tab.label.clone(), + }, + })); + } + + pub(super) fn open_rename_pane_overlay(&mut self) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(pane_id) = snapshot.focused_pane_id.as_deref() else { + return; + }; + let Some(pane) = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id) else { + return; + }; + self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: pane.label.clone().unwrap_or_default(), + replace_on_type: pane.label.is_none(), + target: ClientRenameTarget::Pane { + pane_id: pane.pane_id.clone(), + }, + })); + } + + pub(super) fn insert_overlay_text(&mut self, text: &str) -> bool { + if self.insert_worktree_overlay_text(text) { + return true; + } + match self.overlay.as_mut() { + Some(ClientShellOverlay::Rename(rename)) => { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + rename.input.push_str(text); + true + } + Some(ClientShellOverlay::Help(help)) if help.search_focused => { + help.query + .extend(text.chars().filter(|character| !character.is_control())); + help.scroll = 0; + true + } + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { + navigator.query.push_str(text); + navigator.filter = None; + navigator.selected = 0; + true + } + _ => false, + } + } + + pub(super) fn route_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) { + use crossterm::event::KeyModifiers; + + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if matches!( + key.code, + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') + ) { + self.complete_onboarding(outcome); + } + return; + } + + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_product_announcement(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_product_announcement(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_product_announcement(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_product_announcement(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_product_announcement(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + if let Some(ClientShellOverlay::ProductAnnouncement(announcement)) = + self.overlay.as_mut() + { + announcement.scroll = + u16::try_from(self.hits.product_announcement_max_scroll) + .unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + match key.code { + KeyCode::Enter | KeyCode::Esc => self.dismiss_release_notes(outcome), + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_release_notes(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_release_notes(1); + outcome.repaint = true; + } + KeyCode::PageUp => { + self.scroll_release_notes(-8); + outcome.repaint = true; + } + KeyCode::PageDown => { + self.scroll_release_notes(8); + outcome.repaint = true; + } + KeyCode::Home => { + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = 0; + } + outcome.repaint = true; + } + KeyCode::End => { + let max_scroll = self.current_release_notes_max_scroll(); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = self.overlay.as_mut() { + notes.scroll = u16::try_from(max_scroll).unwrap_or(u16::MAX); + } + outcome.repaint = true; + } + _ => {} + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_global_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_global_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::GlobalMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_global_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_settings_key(key, outcome) { + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + match key.code { + KeyCode::Esc => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Up => { + self.move_context_menu_selection(-1); + outcome.repaint = true; + } + KeyCode::Down => { + self.move_context_menu_selection(1); + outcome.repaint = true; + } + KeyCode::Enter => { + let highlighted = match self.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.highlighted, + _ => return, + }; + self.activate_context_menu_item(highlighted, outcome); + } + _ => {} + } + return; + } + + if self.route_worktree_overlay_key(key, outcome) { + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + ); + if code == KeyCode::Esc { + if search_focused { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = false; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Enter { + self.accept_navigator_selection(outcome); + return; + } + if search_focused { + if code == KeyCode::Up + || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Down + || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL) + { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } else if code == KeyCode::Backspace { + navigator.query.pop(); + navigator.filter = None; + navigator.selected = 0; + } else if let KeyCode::Char(character) = code { + if modifiers.difference(KeyModifiers::SHIFT).is_empty() { + navigator.filter = None; + if let Some(text) = key.generated_text.as_deref() { + navigator.query.push_str(text); + } else { + navigator.query.push(character); + } + navigator.selected = 0; + } + } + outcome.repaint = true; + } + return; + } + if code == KeyCode::Backspace && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + if navigator.filter.take().is_some() { + navigator.selected = 0; + } + } + outcome.repaint = true; + return; + } + if code == KeyCode::Home && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = 0; + navigator.scroll = 0; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { + let last = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + Some(render::client_navigator_rows(snapshot, navigator).len()) + } + _ => None, + }) + .unwrap_or(0) + .saturating_sub(1); + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.selected = last; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('/') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_navigator_selection(1); + outcome.repaint = true; + return; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_navigator_selection(-1); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('d') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(8); + outcome.repaint = true; + return; + } + if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { + self.move_navigator_selection(-8); + outcome.repaint = true; + return; + } + if let Some(filter) = match code { + KeyCode::Char('b') if modifiers.is_empty() => Some(ClientNavigatorFilter::Blocked), + KeyCode::Char('w') if modifiers.is_empty() => Some(ClientNavigatorFilter::Working), + KeyCode::Char('i') if modifiers.is_empty() => Some(ClientNavigatorFilter::Idle), + KeyCode::Char('d') if modifiers.is_empty() => Some(ClientNavigatorFilter::Done), + _ => None, + } { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = Some(filter); + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char('a') && modifiers.is_empty() { + if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { + navigator.query.clear(); + navigator.filter = None; + navigator.selected = 0; + } + outcome.repaint = true; + return; + } + if code == KeyCode::Char(' ') && modifiers.is_empty() { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + return; + } + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + let text_character = crate::input::keybind_help_text_char(key); + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + match code { + KeyCode::Esc => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } + KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up => -1, + KeyCode::Down => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + KeyCode::Backspace => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.pop(); + help.scroll = 0; + } + } + KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.clear(); + help.scroll = 0; + } + } + _ => { + if let Some(character) = text_character { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.query.push(character); + help.scroll = 0; + } + } + } + } + outcome.repaint = true; + return; + } + + match code { + KeyCode::Esc | KeyCode::Enter => self.overlay = None, + KeyCode::Home => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = 0; + } + } + KeyCode::End => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = self.hits.help_max_scroll; + } + } + KeyCode::Up + | KeyCode::Char('k') + | KeyCode::Down + | KeyCode::Char('j') + | KeyCode::PageUp + | KeyCode::PageDown => { + let delta = match code { + KeyCode::Up | KeyCode::Char('k') => -1, + KeyCode::Down | KeyCode::Char('j') => 1, + KeyCode::PageUp => -8, + KeyCode::PageDown => 8, + _ => unreachable!(), + }; + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.scroll = help + .scroll + .saturating_add_signed(delta) + .min(self.hits.help_max_scroll); + } + } + _ if text_character == Some('/') => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = true; + help.scroll = 0; + } + } + _ if text_character == Some('?') => self.overlay = None, + _ => {} + } + outcome.repaint = true; + return; + } + + if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { + if key.code == KeyCode::Enter { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } else if key.code == KeyCode::Esc { + self.overlay = None; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + } + return; + } + + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() else { + return; + }; + if key.code == KeyCode::Enter { + self.save_rename_overlay(outcome); + return; + } + if key.code == KeyCode::Esc { + self.overlay = None; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL)) + || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER)) + { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace + && (key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT)) + || matches!(key.code, KeyCode::Char('h' | 'w')) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + delete_overlay_word(rename); + outcome.repaint = true; + return; + } + if key.code == KeyCode::Backspace { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } else { + rename.input.pop(); + } + outcome.repaint = true; + return; + } + if let KeyCode::Char(character) = key.code { + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + if rename.replace_on_type { + rename.input.clear(); + rename.replace_on_type = false; + } + if let Some(text) = key.generated_text.as_deref() { + rename.input.push_str(text); + } else { + rename.input.push(character); + } + outcome.repaint = true; + } + } + } + + pub(super) fn save_rename_overlay(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Rename(rename)) = self.overlay.take() else { + return; + }; + let trimmed = rename.input.trim(); + let method = match rename.target { + ClientRenameTarget::NewWorkspace { + source_workspace_id, + cwd, + suggested_name, + } => Some(crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id, + cwd, + focus: true, + label: (!trimmed.is_empty() && trimmed != suggested_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Workspace { workspace_id } => (!trimmed.is_empty()).then(|| { + crate::api::schema::Method::WorkspaceRename( + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: trimmed.to_owned(), + }, + ) + }), + ClientRenameTarget::NewTab { + workspace_id, + default_name, + } => Some(crate::api::schema::Method::TabCreate( + crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: true, + label: (!trimmed.is_empty() && trimmed != default_name) + .then(|| trimmed.to_owned()), + env: Default::default(), + }, + )), + ClientRenameTarget::Tab { + tab_id, + auto_name, + original_name, + } => (!(trimmed.is_empty() || auto_name && trimmed == original_name)).then(|| { + crate::api::schema::Method::TabRename(crate::api::schema::TabRenameParams { + tab_id, + label: trimmed.to_owned(), + }) + }), + ClientRenameTarget::Pane { pane_id } => Some(crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(trimmed.to_owned()), + }, + )), + }; + if let Some(method) = method { + self.push_endpoint_method(method, outcome); + } + outcome.repaint = true; + } + + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let Some(workspace) = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + else { + return; + }; + let group_key = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + .map(|worktree| worktree.key.as_str()); + let group = group_key + .map(|key| { + snapshot + .workspaces + .iter() + .filter(|member| { + member + .worktree + .as_ref() + .is_some_and(|worktree| worktree.key == key) + }) + .collect::>() + }) + .unwrap_or_else(|| vec![workspace]); + let closes_group = group.len() > 1; + let pane_count = group + .iter() + .map(|member| { + snapshot + .panes + .iter() + .filter(|pane| pane.workspace_id == member.workspace_id) + .count() + }) + .sum::(); + let panes = if pane_count == 1 { + "1 pane".to_owned() + } else { + format!("{pane_count} panes") + }; + let scope = if closes_group { + format!("{} workspaces, {panes}", group.len()) + } else { + panes + }; + self.overlay = Some(ClientShellOverlay::ConfirmClose( + ClientConfirmCloseOverlay { + workspace_id, + title: if closes_group { + "Close worktree group?".to_owned() + } else { + "Close workspace?".to_owned() + }, + detail: format!("{} — {scope}", workspace.label), + }, + )); + } +} diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs new file mode 100644 index 00000000..30ac78b9 --- /dev/null +++ b/src/client/shell/overlays.rs @@ -0,0 +1,1169 @@ +use super::*; + +mod settings_overlay; +mod worktree_overlays; + +#[derive(Default)] +pub(crate) struct OverlayRender { + pub(crate) primary: Rect, + pub(crate) clear: Rect, + pub(crate) cancel: Rect, + pub(crate) navigator_popup: Rect, + pub(crate) navigator_search: Rect, + pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) worktree_search: Rect, + pub(crate) worktree_rows: Vec<(Rect, usize)>, + pub(crate) help_popup: Rect, + pub(crate) help_scrollbar: Rect, + pub(crate) help_scroll_metrics: Option, + pub(crate) help_max_scroll: usize, + pub(crate) settings_popup: Rect, + pub(crate) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(crate) settings_choices: Vec<(Rect, usize)>, + pub(crate) product_announcement_scrollbar: Rect, + pub(crate) product_announcement_scroll_metrics: Option, + pub(crate) product_announcement_max_scroll: usize, + pub(crate) release_notes_scrollbar: Rect, + pub(crate) release_notes_scroll_metrics: Option, + pub(crate) release_notes_max_scroll: usize, + pub(crate) cursor: Option, +} + +pub(crate) fn render_client_overlay( + b: &mut Buffer, + o: &ClientShellOverlay, + s: &ClientShellSnapshot, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option { + if !matches!( + o, + ClientShellOverlay::Navigator(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ) { + for y in b.area.y..b.area.bottom() { + for x in b.area.x..b.area.right() { + let c = &mut b[(x, y)]; + c.set_style(c.style().add_modifier(Modifier::DIM)); + } + } + } + match o { + ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p), + ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p), + ClientShellOverlay::ReleaseNotes(v) => { + render_release_notes_overlay(b, v, &s.update_install_command, p) + } + ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), + ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), + ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), + ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Settings(v) => { + settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) + } + ClientShellOverlay::WorktreeCreate(v) => { + worktree_overlays::render_worktree_create_overlay(b, v, p) + } + ClientShellOverlay::WorktreeOpen(v) => { + worktree_overlays::render_worktree_open_overlay(b, v, p) + } + ClientShellOverlay::WorktreeRemove(v) => { + worktree_overlays::render_worktree_remove_overlay(b, v, p) + } + ClientShellOverlay::ContextMenu(_) | ClientShellOverlay::GlobalMenu(_) => None, + } +} + +pub(crate) fn render_global_menu( + buffer: &mut Buffer, + launcher: Rect, + menu: &ClientGlobalMenuOverlay, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) -> Option> { + let items = super::super::global_menu::global_menu_items(snapshot); + let screen = buffer.area; + let width = items + .iter() + .map(|(label, action)| { + display_width(label) + + u16::from(super::super::global_menu::global_menu_item_has_badge( + snapshot, *action, + )) * 2 + }) + .max() + .unwrap_or(8) + .saturating_add(4) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = launcher + .right() + .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 mut rows = Vec::new(); + for (index, (label, action)) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + let has_badge = super::super::global_menu::global_menu_item_has_badge(snapshot, *action); + if has_badge { + let badge_style = if highlighted { + style + } else { + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD) + }; + put_text(buffer, row.x, row.y, row.width.min(2), " ●", badge_style); + put_text( + buffer, + row.x.saturating_add(2), + row.y, + row.width.saturating_sub(2), + &format!(" {label}"), + style, + ); + } else { + put_text(buffer, row.x, row.y, row.width, &format!(" {label}"), style); + } + rows.push((row, index)); + } + Some(rows) +} + +pub(crate) fn render_context_menu( + buffer: &mut Buffer, + menu: &ClientContextMenuOverlay, + palette: &Palette, +) -> Option> { + let items = menu.items(); + let screen = buffer.area; + let max_item_width = items + .iter() + .map(|item| display_width(item.label)) + .max() + .unwrap_or(0); + let width = max_item_width + .saturating_add(4) + .max(14) + .min(screen.width.max(1)); + let height = (items.len() as u16) + .saturating_add(2) + .min(screen.height.max(1)); + let x = menu + .x + .min(screen.x.saturating_add(screen.width.saturating_sub(width))); + let y = menu.y.min( + screen + .y + .saturating_add(screen.height.saturating_sub(height)), + ); + 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, item) in items.iter().enumerate() { + let row_y = inner.y.saturating_add(index as u16); + if row_y >= inner.bottom() { + break; + } + let row = Rect::new(inner.x, row_y, inner.width, 1); + let highlighted = index == menu.highlighted; + let style = if highlighted { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + }; + buffer.set_style(row, style); + put_text(buffer, row.x, row.y, row.width, item.label, style); + rows.push((row, index)); + } + Some(rows) +} + +fn panel( + b: &mut Buffer, + a: Rect, + c: ratatui::style::Color, + bg: ratatui::style::Color, +) -> Option { + if a.width < 2 || a.height < 2 { + return None; + } + let background = Style::default().bg(bg).remove_modifier(Modifier::DIM); + let border = Style::default().fg(c).bg(bg).remove_modifier(Modifier::DIM); + for y in a.y..a.bottom() { + for x in a.x..a.right() { + b[(x, y)].set_symbol(" ").set_style(background); + } + } + for x in a.x..a.right() { + b[(x, a.y)] + .set_symbol(if x == a.x { + "┌" + } else if x + 1 == a.right() { + "┐" + } else { + "─" + }) + .set_style(border); + let y = a.bottom() - 1; + b[(x, y)] + .set_symbol(if x == a.x { + "└" + } else if x + 1 == a.right() { + "┘" + } else { + "─" + }) + .set_style(border); + } + for y in a.y + 1..a.bottom() - 1 { + b[(a.x, y)].set_symbol("│").set_style(border); + b[(a.right() - 1, y)].set_symbol("│").set_style(border); + } + Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2)) +} +fn popup(a: Rect, w: u16, h: u16) -> Option { + let w = w.min(a.width.saturating_sub(4)); + let h = h.min(a.height.saturating_sub(2)); + if w < 4 || h < 4 { + return None; + } + Some(Rect::new( + a.x + (a.width - w) / 2, + a.y + (a.height - h) / 2, + w, + h, + )) +} +fn button(b: &mut Buffer, r: Rect, t: &str, s: Style) { + b.set_style(r, s); + let w = display_width(t).min(r.width); + put_text(b, r.x + (r.width - w) / 2, r.y, w, t, s) +} +fn row(i: Rect, ws: &[u16], gap: u16, off: u16) -> Vec { + let total = ws.iter().sum::() + gap * (ws.len().saturating_sub(1) as u16); + let mut x = i.x + i.width.saturating_sub(total) / 2; + ws.iter() + .map(|w| { + let r = Rect::new( + x, + i.y + off.min(i.height.saturating_sub(1)), + (*w).min(i.width.saturating_sub(x - i.x)), + 1, + ); + x += *w + gap; + r + }) + .collect() +} +fn contrast(p: &Palette) -> ratatui::style::Color { + match p.panel_bg { + ratatui::style::Color::Reset => p.surface_dim, + c => c, + } +} +fn render_release_notes_overlay( + b: &mut Buffer, + notes: &crate::app::state::ReleaseNotesState, + install_command: &str, + p: &Palette, +) -> Option { + let outer = popup( + b.area, + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &format!("v{}", notes.version), + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + if notes.preview { + "update ready" + } else { + "what's new in this release" + }, + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::release_notes_display_lines(notes, install_command, p); + let metrics = crate::ui::release_notes_scroll_metrics(notes, install_command, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(notes.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + release_notes_scrollbar: track.unwrap_or_default(), + release_notes_scroll_metrics: Some(metrics), + release_notes_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_product_announcement_overlay( + b: &mut Buffer, + announcement: &crate::app::state::ProductAnnouncementState, + p: &Palette, +) -> Option { + let outer = popup( + b.area, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, + crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, + )?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 8 || inner.width < 20 { + return Some(OverlayRender::default()); + } + + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let title_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y, + stack.header.width.saturating_sub(2), + 1, + ); + let subtitle_area = Rect::new( + stack.header.x.saturating_add(1), + stack.header.y.saturating_add(1), + stack.header.width.saturating_sub(2), + 1, + ); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + put_text( + b, + title_area.x, + title_area.y, + title_area.width, + &announcement.title, + base.fg(p.text).add_modifier(Modifier::BOLD), + ); + let subtitle = if announcement.preview { + "product announcement preview" + } else { + "product announcement" + }; + put_text( + b, + subtitle_area.x, + subtitle_area.y, + subtitle_area.width, + &format!("{subtitle} · v{}", announcement.version), + base.fg(p.overlay1), + ); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + button( + b, + close, + " esc close ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + + let body = stack.content; + let lines = crate::ui::product_announcement_display_lines(announcement, p); + let metrics = crate::ui::product_announcement_scroll_metrics(announcement, body, p); + let max_scroll = metrics.max_offset_from_bottom; + let scroll = usize::from(announcement.scroll).min(max_scroll); + let track = crate::ui::release_notes_scrollbar_rect(body, metrics); + let text_area = track + .map(|_| Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height)) + .unwrap_or(body); + let paragraph = ratatui::widgets::Paragraph::new( + lines.into_iter().map(|(_, line)| line).collect::>(), + ) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + ratatui::widgets::Widget::render(paragraph, text_area, b); + if let Some(track) = track { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } + + if let Some(footer_area) = stack.footer { + let footer_line = ratatui::text::Line::from(vec![ + ratatui::text::Span::styled(" scroll ", base.fg(p.overlay0)), + ratatui::text::Span::styled("wheel ↑↓", base.fg(p.text)), + ratatui::text::Span::styled(" · ", base.fg(p.overlay0)), + ratatui::text::Span::styled("close", base.fg(p.overlay0)), + ratatui::text::Span::styled(" esc / enter ", base.fg(p.text)), + ]); + ratatui::widgets::Widget::render( + ratatui::widgets::Paragraph::new(footer_line), + footer_area, + b, + ); + } + + Some(OverlayRender { + primary: close, + product_announcement_scrollbar: track.unwrap_or_default(), + product_announcement_scroll_metrics: Some(metrics), + product_announcement_max_scroll: max_scroll, + ..OverlayRender::default() + }) +} + +fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option { + let outer = popup(b.area, 64, 16)?; + let inner = panel(b, outer, p.accent, p.panel_bg)?; + if inner.height < 11 { + return Some(OverlayRender::default()); + } + let stack = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1); + let base = Style::default() + .bg(p.panel_bg) + .remove_modifier(Modifier::DIM); + let title = base.fg(p.text).add_modifier(Modifier::BOLD); + let muted = base.fg(p.overlay0); + let text = base.fg(p.overlay1); + let accent = base.fg(p.accent).add_modifier(Modifier::BOLD); + + put_text( + b, + stack.header.x, + stack.header.y, + stack.header.width, + crate::ui::ONBOARDING_TITLE, + title, + ); + put_text( + b, + stack.header.x, + stack.header.y.saturating_add(1), + stack.header.width, + crate::ui::ONBOARDING_SUBTITLE, + muted, + ); + + let content = stack.content; + for (offset, line) in crate::ui::ONBOARDING_DESCRIPTION.iter().enumerate() { + put_text( + b, + content.x, + content.y.saturating_add(offset as u16), + content.width, + line, + text, + ); + } + + let key_y = content.y.saturating_add(4); + let mut key_x = content.x; + for (value, style) in [ + (" ", base), + (crate::ui::ONBOARDING_PREFIX_LABEL, accent), + (crate::ui::ONBOARDING_PREFIX_SUFFIX, text), + (crate::ui::ONBOARDING_HELP_LABEL, accent), + (crate::ui::ONBOARDING_HELP_SUFFIX, text), + ] { + let width = display_width(value); + put_text( + b, + key_x, + key_y, + content.right().saturating_sub(key_x), + value, + style, + ); + key_x = key_x.saturating_add(width); + } + put_text( + b, + content.x, + content.y.saturating_add(5), + content.width, + crate::ui::ONBOARDING_NEXT, + text, + ); + + let primary = crate::ui::onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); + button( + b, + primary, + " ↵ continue ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM), + ); + Some(OverlayRender { + primary, + ..OverlayRender::default() + }) +} + +fn render_rename_overlay( + b: &mut Buffer, + v: &ClientRenameOverlay, + p: &Palette, +) -> Option { + let q = popup(b.area, 56, 7)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + v.title, + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let input = Rect::new(i.x, i.y + 2, i.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width.saturating_sub(1), + &format!(" {}", v.input), + Style::default().fg(p.text).bg(p.surface0), + ); + let rs = row(i, &[8, 10, 12], 2, 3); + let [save, clear, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *save, + " ↵ save ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let n = Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD); + button(b, *clear, " ^c clear ", n); + button(b, *cancel, " esc cancel ", n); + Some(OverlayRender { + primary: *save, + clear: *clear, + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: Some(crate::protocol::CursorState { + x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(crate) fn client_navigator_rows( + s: &ClientShellSnapshot, + n: &ClientNavigatorOverlay, +) -> Vec { + let q = n.query.trim().to_lowercase(); + let filter = |st| match n.filter { + Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); + let filtering = n.filter.is_some() || !q.is_empty(); + let mut out = Vec::new(); + for w in &s.workspaces { + let mut children = Vec::new(); + for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { + let mut panes = Vec::new(); + for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { + let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); + let st = a + .map(|a| a.agent_status) + .unwrap_or(crate::api::schema::AgentStatus::Unknown); + let label = p + .label + .clone() + .or_else(|| a.and_then(|a| a.name.clone())) + .or_else(|| a.and_then(|a| a.display_agent.clone())) + .or_else(|| a.and_then(|a| a.title.clone())) + .unwrap_or_else(|| format!("pane {}", ix + 1)); + let meta = p + .foreground_cwd + .clone() + .or_else(|| p.cwd.clone()) + .unwrap_or_default(); + if !filtering || filter(st) && (text(&label) || text(&meta)) { + panes.push(ClientNavigatorRow { + depth: 2, + label, + meta, + status: st, + current: s.focused_pane_id.as_deref() == Some(&p.pane_id), + target: ClientNavigatorTarget::Pane(p.pane_id.clone()), + }) + } + } + if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { + children.push(ClientNavigatorRow { + depth: 1, + label: t.label.clone(), + meta: format!( + "{} panes", + s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() + ), + status: t.agent_status, + current: s.focused_tab_id.as_deref() == Some(&t.tab_id), + target: ClientNavigatorTarget::Tab(t.tab_id.clone()), + }); + children.extend(panes) + } + } + let wm = + filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); + if !filtering || wm || !children.is_empty() { + out.push(ClientNavigatorRow { + depth: 0, + label: w.label.clone(), + meta: w.branch.clone().unwrap_or_default(), + status: w.agent_status, + current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), + target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), + }); + if n.expanded_workspaces.contains(&w.workspace_id) || filtering { + out.extend(children) + } + } + } + out +} + +fn render_navigator_overlay( + b: &mut Buffer, + n: &ClientNavigatorOverlay, + s: &ClientShellSnapshot, + p: &Palette, +) -> Option { + let a = b.area; + let mx = (a.width / 16).max(2); + let my = (a.height / 10).max(1); + let q = Rect::new( + a.x + mx, + a.y + my, + a.width.saturating_sub(mx * 2).max(4), + a.height.saturating_sub(my * 2).max(4), + ); + let i = panel(b, q, p.accent, p.panel_bg)?; + let rows = client_navigator_rows(s, n); + let search = if n.search_focused { + format!(" / {}", n.query) + } else if let Some(f) = n.filter { + format!( + " / {}", + match f { + ClientNavigatorFilter::Blocked => "blocked", + ClientNavigatorFilter::Working => "working", + ClientNavigatorFilter::Idle => "idle", + ClientNavigatorFilter::Done => "done", + } + ) + } else if n.query.is_empty() { + " / search panes".to_owned() + } else { + format!(" / {}", n.query) + }; + put_text( + b, + i.x, + i.y, + i.width, + &search, + Style::default() + .fg(if n.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + put_right_text( + b, + i, + i.y, + &format!("{} panes", s.panes.len()), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &"─".repeat(i.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let max = rows.len().saturating_sub(body.height as usize); + let scroll = n + .scroll + .max( + n.selected + .saturating_sub(body.height.saturating_sub(1) as usize), + ) + .min(n.selected) + .min(max); + let mut row_hits = Vec::new(); + for (vis, (ix, r)) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + .enumerate() + { + let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); + row_hits.push((rect, ix)); + let st = if ix == n.selected { + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(if r.current { p.text } else { p.subtext0 }) + .bg(p.panel_bg) + }; + b.set_style(rect, st); + let tree = match r.depth { + 0 => match &r.target { + ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", + _ => "▸", + }, + 1 => "└──", + _ => " └──", + }; + let label = format!( + " {} {} {} {}", + if r.current { "◆" } else { " " }, + tree, + status_dot(r.status), + r.label + ); + put_text(b, rect.x, rect.y, rect.width, &label, st); + if !r.meta.is_empty() { + let label_width = display_width(&label).min(rect.width); + let meta = Rect::new( + rect.x.saturating_add(label_width).saturating_add(1), + rect.y, + rect.width.saturating_sub(label_width.saturating_add(1)), + 1, + ); + put_right_text(b, meta, rect.y, &r.meta, st) + } + } + let dy = i.bottom() - 2; + if let Some(r) = rows.get(n.selected) { + put_text( + b, + i.x, + dy, + i.width, + &format!(" {} · {}", r.label, r.meta), + Style::default().fg(p.overlay0).bg(p.panel_bg), + ) + } + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if n.search_focused { + " search type · move ↑↓/ctrl+n/p · open enter · back esc" + } else { + " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + primary: Rect::default(), + clear: Rect::default(), + cancel: Rect::default(), + navigator_popup: q, + navigator_search: Rect::new(i.x, i.y, i.width, 1), + navigator_rows: row_hits, + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: n.search_focused.then(|| crate::protocol::CursorState { + x: i.x + 3 + display_width(&n.query), + y: i.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +fn help_lines( + keybinds: &LiveKeybindConfig, + query: &str, + palette: &Palette, +) -> Vec<(usize, ratatui::text::Line<'static>)> { + use ratatui::text::{Line, Span}; + + let groups = crate::input::filter_keybind_help_groups( + crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + query, + ); + let key_width = groups + .iter() + .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) + .max() + .unwrap_or(8); + if groups.is_empty() { + let message = " no matching keybinds"; + return vec![( + message.chars().count(), + Line::from(Span::styled( + message, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + )), + )]; + } + + let mut lines = Vec::new(); + for (group, entries) in groups { + lines.push(( + group.len() + 1, + Line::from(Span::styled( + format!(" {group}"), + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + )), + )); + for (key, label) in entries { + let padded_key = format!(" {key: Option { + use ratatui::widgets::{Paragraph, Widget, Wrap}; + + let q = popup(b.area, 76, 22)?; + let i = panel(b, q, p.accent, p.panel_bg)?; + if i.width < 20 || i.height < 6 { + return None; + } + put_text( + b, + i.x, + i.y, + i.width, + "keybinds", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let close = Rect::new(i.right() - 13, i.y, 13, 1); + button( + b, + close, + if h.search_focused { + " esc back " + } else { + " esc close " + }, + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + let sy = i.y + 1; + put_text( + b, + i.x, + sy, + i.width, + &if h.search_focused { + format!(" / {}", h.query) + } else { + " / press / to filter by command or shortcut".to_owned() + }, + Style::default() + .fg(if h.search_focused { p.text } else { p.overlay0 }) + .bg(p.panel_bg), + ); + + let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5)); + let lines = help_lines(k, &h.query, p); + let viewport_rows = usize::from(body.height.max(1)); + let wrapped_rows = |width: u16| { + let width = usize::from(width.max(1)); + lines + .iter() + .map(|(line_width, _)| line_width.max(&1).div_ceil(width)) + .sum::() + }; + let needs_scrollbar = wrapped_rows(body.width) > viewport_rows; + let text_area = if needs_scrollbar { + Rect::new(body.x, body.y, body.width.saturating_sub(1), body.height) + } else { + body + }; + let total_rows = wrapped_rows(text_area.width); + let max_scroll = total_rows.saturating_sub(viewport_rows); + let scroll = h.scroll.min(max_scroll); + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, + viewport_rows, + }; + let scrollbar = needs_scrollbar.then_some(Rect::new( + body.right().saturating_sub(1), + body.y, + 1, + body.height, + )); + Widget::render( + Paragraph::new(lines.into_iter().map(|(_, line)| line).collect::>()) + .wrap(Wrap { trim: false }) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + text_area, + b, + ); + if let Some(track) = scrollbar { + if let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) { + for y in track.y..track.bottom() { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay0).bg(p.panel_bg)); + } + for y in thumb.top..thumb.top.saturating_add(thumb.len) { + b[(track.x, y)] + .set_symbol("▐") + .set_style(Style::default().fg(p.overlay1).bg(p.panel_bg)); + } + } + } + + put_text( + b, + i.x, + i.bottom() - 1, + i.width, + if h.search_focused { + " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc" + } else { + " search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter" + }, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + Some(OverlayRender { + cancel: close, + help_popup: q, + help_scrollbar: scrollbar.unwrap_or_default(), + help_scroll_metrics: Some(metrics), + help_max_scroll: max_scroll, + cursor: h.search_focused.then(|| crate::protocol::CursorState { + x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1), + y: sy, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} +fn render_confirm_close_overlay( + b: &mut Buffer, + c: &ClientConfirmCloseOverlay, + p: &Palette, +) -> Option { + let q = popup(b.area, 64, 6)?; + let i = panel(b, q, p.red, p.panel_bg)?; + put_text( + b, + i.x, + i.y, + i.width, + &format!(" {}", c.title), + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + i.x, + i.y + 1, + i.width, + &format!(" {}", c.detail), + Style::default().fg(p.text).bg(p.panel_bg), + ); + let rs = row(i, &[13, 12], 2, 3); + let [ok, cancel] = rs.as_slice() else { + return None; + }; + button( + b, + *ok, + " ↵ confirm ", + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *ok, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs new file mode 100644 index 00000000..dc6cc1ac --- /dev/null +++ b/src/client/shell/preferences.rs @@ -0,0 +1,132 @@ +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct ClientChromePreferences { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_section_split: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sidebar_collapsed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) agent_panel_sort: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) collapsed_groups: Vec, +} + +pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { + let mut hash = 0xcbf29ce484222325u64; + for byte in socket_path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + crate::config::state_dir() + .join("client-shell") + .join(format!("local-{hash:016x}.json")) +} + +pub(super) fn load(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(super) fn store(path: &Path, preferences: ClientChromePreferences) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create client shell state directory: {error}"))?; + let content = serde_json::to_vec_pretty(&preferences) + .map_err(|error| format!("failed to encode client shell state: {error}"))?; + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let mut temp_name = path + .file_name() + .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))? + .to_os_string(); + temp_name.push(format!(".tmp-{}-{sequence}", std::process::id())); + let temp_path = parent.join(temp_name); + std::fs::write(&temp_path, content) + .map_err(|error| format!("failed to write client shell state: {error}"))?; + crate::platform::replace_file(&temp_path, path).map_err(|error| { + let _ = std::fs::remove_file(&temp_path); + format!("failed to replace client shell state: {error}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_paths_are_stable_and_distinct() { + let first = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let again = path_for_local_endpoint(Path::new("/run/herdr/one.sock")); + let second = path_for_local_endpoint(Path::new("/run/herdr/two.sock")); + assert_eq!(first, again); + assert_ne!(first, second); + } + + #[test] + fn concurrent_stores_leave_complete_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-concurrent-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let writers = (20..28) + .map(|width| { + let path = path.clone(); + std::thread::spawn(move || { + store( + &path, + ClientChromePreferences { + sidebar_width: Some(width), + ..ClientChromePreferences::default() + }, + ) + }) + }) + .collect::>(); + for writer in writers { + writer.join().expect("preference writer").expect("store"); + } + assert!(load(&path) + .and_then(|saved| saved.sidebar_width) + .is_some_and(|width| (20..28).contains(&width))); + std::fs::remove_file(path).expect("remove preferences"); + } + + #[test] + fn repeated_store_replaces_existing_preferences() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-preferences-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(24), + ..ClientChromePreferences::default() + }, + ) + .expect("first preference store"); + store( + &path, + ClientChromePreferences { + sidebar_width: Some(32), + ..ClientChromePreferences::default() + }, + ) + .expect("replacement preference store"); + assert_eq!(load(&path).and_then(|saved| saved.sidebar_width), Some(32)); + std::fs::remove_file(path).expect("remove preferences"); + } +} diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs new file mode 100644 index 00000000..0e7f3908 --- /dev/null +++ b/src/client/shell/render.rs @@ -0,0 +1,313 @@ +use super::*; + +#[path = "../shell/overlays.rs"] +mod overlays; +#[path = "../shell/sidebar.rs"] +mod sidebar; +#[path = "../shell/tabs.rs"] +mod tabs; + +pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; +pub(super) use overlays::{ + client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, +}; +pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; +pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; +pub(super) fn render_mode_bar( + buffer: &mut Buffer, + pane_area: Rect, + mode: ClientShellMode, + copy_mode: Option<&ClientCopyModeState>, + endpoint_error: Option<&str>, + update_available: bool, + keybinds: &LiveKeybindConfig, + palette: &Palette, +) -> Option { + if (mode == ClientShellMode::Terminal && endpoint_error.is_none()) || pane_area.is_empty() { + return None; + } + + let bar = Rect::new( + pane_area.x, + pane_area.y + pane_area.height.saturating_sub(1), + pane_area.width, + 1, + ); + let base = Style::default().fg(palette.overlay0).bg(palette.panel_bg); + for x in bar.x..bar.x + bar.width { + buffer[(x, bar.y)].set_symbol(" ").set_style(base); + } + + let key = Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD); + let mode_style = Style::default() + .fg(match palette.panel_bg { + ratatui::style::Color::Reset => palette.surface_dim, + color => color, + }) + .bg(if mode == ClientShellMode::Resize { + palette.mauve + } else { + palette.accent + }) + .add_modifier(Modifier::BOLD); + let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { + bindings + .prefix_rhs_label() + .unwrap_or_else(|| "unset".to_owned()) + }; + + let mut segments = Vec::<(String, Style)>::new(); + if let Some(error) = endpoint_error { + segments.extend([ + (" ERROR ".to_owned(), mode_style), + (format!(" {error}"), base), + ]); + } else { + match mode { + ClientShellMode::Prefix => { + segments.extend([ + (" PREFIX ".to_owned(), mode_style), + (" ".to_owned(), base), + ("esc".to_owned(), key), + (" cancel ".to_owned(), base), + (prefix, key), + (" send prefix ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.workspace_picker), key), + (" workspace nav ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Navigate => { + segments.extend([ + (" NAVIGATE ".to_owned(), mode_style), + (" esc back ".to_owned(), base), + ("↑/↓".to_owned(), key), + (" workspace ".to_owned(), base), + ("tab".to_owned(), key), + (" pane ".to_owned(), base), + (prefix_rhs(&keybinds.keybinds.help), key), + (" keybinds".to_owned(), base), + ]); + } + ClientShellMode::Resize => { + segments.extend([ + (" RESIZE ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/l".to_owned(), key), + (" width ".to_owned(), base), + ("j/k".to_owned(), key), + (" height ".to_owned(), base), + ("esc".to_owned(), key), + (" done".to_owned(), base), + ]); + } + ClientShellMode::Copy => { + let copy_mode = copy_mode?; + if let Some(prompt) = copy_mode.search_prompt.as_ref() { + let marker = match prompt.direction { + crate::api::schema::PaneCopySearchDirection::Forward => "/", + crate::api::schema::PaneCopySearchDirection::Backward => "?", + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + (marker.to_owned(), key), + ( + prompt.query.clone(), + Style::default().fg(palette.text).bg(palette.panel_bg), + ), + ("█".to_owned(), key), + (" enter search esc cancel".to_owned(), base), + ]); + } else { + let select = if copy_mode.selection.is_some() { + "selecting" + } else { + "select" + }; + let match_status = copy_mode + .search_current_global + .map(|current| format!(" {}/{}", current + 1, copy_mode.search_total)) + .or_else(|| (!copy_mode.search_query.is_empty()).then(|| " 0/0".to_owned())) + .unwrap_or_default(); + let (exit_keys, exit_label) = + if copy_mode.search_query.is_empty() && copy_mode.selection.is_none() { + ("q/esc", " exit") + } else { + ("esc", " clear q exit") + }; + segments.extend([ + (" COPY ".to_owned(), mode_style), + (" ".to_owned(), base), + ("h/j/k/l w/b/e { }".to_owned(), key), + (" move ".to_owned(), base), + ("/ ?".to_owned(), key), + (" search ".to_owned(), base), + ("n/N".to_owned(), key), + (format!(" repeat{match_status} "), base), + ("v/space".to_owned(), key), + (format!(" {select} "), base), + ("y/enter".to_owned(), key), + (" copy ".to_owned(), base), + (exit_keys.to_owned(), key), + (exit_label.to_owned(), base), + ]); + } + } + ClientShellMode::Terminal => unreachable!(), + } + } + + let mut x = bar.x; + let end = bar.x + bar.width; + for (text, style) in segments { + if x >= end { + break; + } + let remaining = end - x; + buffer.set_stringn(x, bar.y, &text, usize::from(remaining), style); + x = x.saturating_add( + u16::try_from(UnicodeWidthStr::width(text.as_str())) + .unwrap_or(u16::MAX) + .min(remaining), + ); + } + if update_available && mode == ClientShellMode::Navigate { + let width = 13.min(bar.width); + let area = Rect::new(bar.right().saturating_sub(width), bar.y, width, 1); + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + put_right_text( + buffer, + area, + area.y, + " update ready", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + Some(bar) +} + +pub(super) struct ShellRenderState<'a> { + pub(super) collapsed_groups: &'a HashSet, + pub(super) workspace_scroll: &'a mut usize, + pub(super) agent_scroll: &'a mut usize, + pub(super) tab_scroll: &'a mut usize, + pub(super) reveal_focused_tab: &'a mut bool, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_section_split: f32, + pub(super) tab_drag_insert_index: Option, + pub(super) selected_workspace_id: Option<&'a str>, + pub(super) dragged_workspace_id: Option<&'a str>, + pub(super) workspace_drop_indicator_row: Option, +} + +pub(super) fn render_shell( + buffer: &mut Buffer, + layout: ClientShellLayout, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + mut state: ShellRenderState<'_>, +) -> ShellHitMap { + let mut hits = ShellHitMap::default(); + if layout.mobile_header.height > 0 { + super::mobile::render_mobile_header( + buffer, + layout.mobile_header, + snapshot, + config, + &mut hits, + ); + } + if layout.sidebar.width > 0 { + if state.sidebar_collapsed { + render_collapsed_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + state.selected_workspace_id, + &mut hits, + ); + } else { + render_sidebar( + buffer, + layout.sidebar, + snapshot, + config, + &mut state, + &mut hits, + ); + } + } + if layout.tab_bar.height > 0 { + render_tab_bar( + buffer, + layout.tab_bar, + snapshot, + config, + state.tab_scroll, + state.reveal_focused_tab, + state.tab_drag_insert_index, + &mut hits, + ); + } + if !config.mouse_capture { + hits.sidebar_divider = Rect::default(); + hits.sidebar_section_divider = Rect::default(); + hits.workspace_scrollbar = Rect::default(); + hits.agent_scrollbar = Rect::default(); + hits.agent_sort_toggle = Rect::default(); + hits.new_workspace = Rect::default(); + hits.workspaces.clear(); + hits.agents.clear(); + hits.tab_scroll_left = Rect::default(); + hits.tab_scroll_right = Rect::default(); + hits.new_tab = Rect::default(); + hits.pane_splits.clear(); + } + hits +} + +fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { + let width = display_width(text).min(area.width); + put_text( + buffer, + area.right().saturating_sub(width), + y, + width, + text, + style, + ); +} + +pub(super) fn put_segment( + buffer: &mut Buffer, + x: u16, + y: u16, + right: u16, + text: &str, + style: Style, +) -> u16 { + let width = display_width(text).min(right.saturating_sub(x)); + put_text(buffer, x, y, width, text, style); + x.saturating_add(width) +} + +pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { + if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() { + return; + } + buffer.set_stringn(x, y, text, width as usize, style); +} + +pub(super) fn display_width(text: &str) -> u16 { + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 +} diff --git a/src/client/shell/scroll.rs b/src/client/shell/scroll.rs new file mode 100644 index 00000000..a0496cb8 --- /dev/null +++ b/src/client/shell/scroll.rs @@ -0,0 +1,98 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + +use super::Palette; + +pub(super) fn list_scroll_metrics( + row_heights: &[u16], + gaps_after: &[u16], + body_height: u16, + requested_start: usize, +) -> crate::pane::ScrollMetrics { + if row_heights.is_empty() || body_height == 0 { + return crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 0, + }; + } + + let mut used = 0u16; + let mut max_start = row_heights.len(); + for index in (0..row_heights.len()).rev() { + let height = row_heights[index].max(1).min(body_height); + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(height).saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(height).saturating_add(gap); + max_start = index; + } + max_start = max_start.min(row_heights.len().saturating_sub(1)); + let start = requested_start.min(max_start); + + let mut viewport_rows = 0usize; + let mut used = 0u16; + for (index, row_height) in row_heights.iter().enumerate().skip(start) { + let height = (*row_height).max(1).min(body_height); + if used.saturating_add(height) > body_height { + break; + } + used = used.saturating_add(height); + viewport_rows += 1; + let gap = gaps_after.get(index).copied().unwrap_or(0); + if used.saturating_add(gap) > body_height { + break; + } + used = used.saturating_add(gap); + } + + crate::pane::ScrollMetrics { + offset_from_bottom: max_start.saturating_sub(start), + max_offset_from_bottom: max_start, + viewport_rows, + } +} + +pub(super) fn render_list_scrollbar( + buffer: &mut Buffer, + track: Rect, + metrics: crate::pane::ScrollMetrics, + palette: &Palette, +) { + let Some(thumb) = crate::ui::scrollbar_thumb(metrics, track) else { + return; + }; + for row in track.y..track.bottom() { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.surface_dim)); + } + } + for row in thumb.top..thumb.top.saturating_add(thumb.len) { + if let Some(cell) = buffer.cell_mut((track.x, row)) { + cell.set_symbol("▕") + .set_style(Style::default().fg(palette.overlay0)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_metrics_preserve_variable_rows_and_caller_owned_gap_policy() { + let top = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, 0); + assert_eq!(top.max_offset_from_bottom, 2); + assert_eq!(top.offset_from_bottom, 2); + assert_eq!(top.viewport_rows, 2); + + let bottom = list_scroll_metrics(&[1, 3, 2], &[1, 1, 0], 5, usize::MAX); + assert_eq!(bottom.offset_from_bottom, 0); + assert_eq!(bottom.viewport_rows, 1); + + let parent_child = list_scroll_metrics(&[2, 2, 2], &[0, 1, 0], 5, 0); + assert_eq!(parent_child.max_offset_from_bottom, 1); + assert_eq!(parent_child.viewport_rows, 2); + } +} diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs new file mode 100644 index 00000000..95a437fe --- /dev/null +++ b/src/client/shell/settings.rs @@ -0,0 +1,385 @@ +use super::*; +use crossterm::event::{KeyCode, KeyModifiers}; + +pub(super) fn normalized_theme_name(name: &str) -> String { + name.to_lowercase().replace([' ', '_'], "-") +} + +fn theme_index(name: &str) -> usize { + let normalized = normalized_theme_name(name); + crate::config::THEME_NAMES + .iter() + .position(|candidate| normalized_theme_name(candidate) == normalized) + .unwrap_or(0) +} + +fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize { + usize::from(style == crate::config::StatusIndicatorStyle::Symbols) +} + +fn toast_index(delivery: crate::config::ToastDelivery) -> usize { + match delivery { + crate::config::ToastDelivery::Off => 0, + crate::config::ToastDelivery::Herdr => 1, + crate::config::ToastDelivery::Terminal => 2, + crate::config::ToastDelivery::System => 3, + } +} + +pub(super) fn integration_needs_install(info: &crate::api::schema::IntegrationInfo) -> bool { + info.state == crate::api::schema::IntegrationState::Outdated + || info.available && info.state == crate::api::schema::IntegrationState::NotInstalled +} + +impl ClientShellState { + pub(super) fn open_settings_overlay(&mut self) { + self.overlay = Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + selected: theme_index(&self.config.theme_name), + original_theme_name: self.config.theme_name.clone(), + original_palette: self.config.palette.clone(), + integrations: Vec::new(), + integration_messages: Vec::new(), + loading_integrations: false, + installing_integrations: false, + })); + } + + fn selected_index_for_settings_section(&self, section: ClientSettingsSection) -> usize { + match section { + ClientSettingsSection::Theme => theme_index(&self.config.theme_name), + ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators), + ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled), + ClientSettingsSection::Toast => toast_index(self.config.toast_delivery), + ClientSettingsSection::Integrations => 0, + } + } + + pub(super) fn select_settings_section( + &mut self, + section: ClientSettingsSection, + outcome: &mut ClientShellInput, + ) { + let selected = self.selected_index_for_settings_section(section); + let request_integrations = matches!(section, ClientSettingsSection::Integrations) + && matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: false, + installing_integrations: false, + .. + })) + ); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.section = section; + settings.selected = selected; + } + if request_integrations { + self.queue_integration_list(outcome, true); + } + outcome.repaint = true; + } + + fn move_settings_section(&mut self, delta: isize, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let current = ClientSettingsSection::ALL + .iter() + .position(|section| *section == settings.section) + .unwrap_or(0); + let next = (current as isize + delta).rem_euclid(ClientSettingsSection::ALL.len() as isize) + as usize; + self.select_settings_section(ClientSettingsSection::ALL[next], outcome); + } + + fn settings_choice_count(&self) -> usize { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => match settings.section { + ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(), + ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2, + ClientSettingsSection::Toast => 4, + ClientSettingsSection::Integrations => settings.integrations.len(), + }, + _ => 0, + } + } + + pub(super) fn move_settings_selection(&mut self, delta: isize) { + let count = self.settings_choice_count(); + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() else { + return; + }; + if count == 0 { + settings.selected = 0; + return; + } + settings.selected = (settings.selected as isize + delta) + .clamp(0, count.saturating_sub(1) as isize) as usize; + if settings.section == ClientSettingsSection::Theme { + self.preview_selected_theme(); + } + } + + pub(super) fn select_settings_choice(&mut self, index: usize) { + let count = self.settings_choice_count(); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + if count > 0 { + settings.selected = index.min(count - 1); + } + } + if matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + ) { + self.preview_selected_theme(); + } + } + + fn preview_selected_theme(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let Some(name) = crate::config::THEME_NAMES.get(settings.selected) else { + return; + }; + self.config.theme_name = (*name).to_owned(); + self.config.palette = + crate::app::client_palette_for_theme(&self.config.theme_runtime, name); + } + + pub(super) fn cancel_settings_overlay(&mut self) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.take() else { + return; + }; + self.config.theme_name = settings.original_theme_name; + self.config.palette = settings.original_palette; + } + + fn save_settings_edit( + &mut self, + edit: crate::config::ConfigEdit<'_>, + outcome: &mut ClientShellInput, + ) -> bool { + if let Err(error) = crate::config::write_edit(edit) { + self.endpoint_error = Some(error); + outcome.repaint = true; + return false; + } + self.reload_client_config(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + outcome.repaint = true; + true + } + + pub(super) fn apply_settings_choice(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else { + return; + }; + let section = settings.section; + let selected = settings.selected; + match section { + ClientSettingsSection::Theme => { + let Some(name) = crate::config::THEME_NAMES.get(selected).copied() else { + return; + }; + if self.save_settings_edit(crate::config::ConfigEdit::Theme(name), outcome) { + self.overlay = None; + } + } + ClientSettingsSection::Indicators => { + let style = if selected == 0 { + crate::config::StatusIndicatorStyle::Dots + } else { + crate::config::StatusIndicatorStyle::Symbols + }; + self.save_settings_edit( + crate::config::ConfigEdit::StatusIndicators(style), + outcome, + ); + } + ClientSettingsSection::Sound => { + self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome); + } + ClientSettingsSection::Toast => { + let delivery = match selected { + 0 => crate::config::ToastDelivery::Off, + 1 => crate::config::ToastDelivery::Herdr, + 2 => crate::config::ToastDelivery::Terminal, + _ => crate::config::ToastDelivery::System, + }; + self.save_settings_edit( + crate::config::ConfigEdit::ToastDelivery(delivery), + outcome, + ); + } + ClientSettingsSection::Integrations => self.install_recommended_integrations(outcome), + } + } + + fn queue_integration_list(&mut self, outcome: &mut ClientShellInput, clear_messages: bool) { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = true; + if clear_messages { + settings.integration_messages.clear(); + } + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()), + PendingEndpointKind::IntegrationList, + outcome, + ); + } + + fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) { + if self.pending_integration_installs > 0 { + return; + } + let targets = match self.overlay.as_ref() { + Some(ClientShellOverlay::Settings(settings)) => settings + .integrations + .iter() + .filter(|integration| integration_needs_install(integration)) + .map(|integration| integration.target) + .collect::>(), + _ => return, + }; + if targets.is_empty() { + return; + } + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.installing_integrations = true; + settings.integration_messages.clear(); + } + self.pending_integration_installs = targets.len(); + for target in targets { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { target }, + ), + PendingEndpointKind::IntegrationInstall, + outcome, + ); + } + outcome.repaint = true; + } + + pub(super) fn handle_settings_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result, + ) -> (bool, Vec) { + match kind { + PendingEndpointKind::IntegrationList => { + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + settings.loading_integrations = false; + match result { + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations, + }) => { + settings.integrations = integrations; + settings.selected = settings + .selected + .min(settings.integrations.len().saturating_sub(1)); + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected integration list result".into(), + ); + } + Err(error) => self.endpoint_error = Some(error.message), + } + } + (true, Vec::new()) + } + PendingEndpointKind::IntegrationInstall => { + self.pending_integration_installs = + self.pending_integration_installs.saturating_sub(1); + if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { + match result { + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + details, + .. + }) => settings.integration_messages.extend(details.messages), + Ok(_) => settings + .integration_messages + .push("endpoint returned an unexpected integration result".into()), + Err(error) => settings.integration_messages.push(error.message), + } + settings.installing_integrations = self.pending_integration_installs > 0; + } + let actions = if self.pending_integration_installs == 0 + && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + let mut deferred = ClientShellInput::default(); + self.queue_integration_list(&mut deferred, false); + deferred.actions + } else { + Vec::new() + }; + (true, actions) + } + _ => (false, Vec::new()), + } + } + + pub(super) fn route_settings_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + return false; + } + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if code == KeyCode::Esc { + if !matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ) { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + return true; + } + if matches!(code, KeyCode::Tab | KeyCode::Right | KeyCode::Char('l')) + && modifiers.is_empty() + { + self.move_settings_section(1, outcome); + return true; + } + if matches!(code, KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h')) + && modifiers.difference(KeyModifiers::SHIFT).is_empty() + { + self.move_settings_section(-1, outcome); + return true; + } + if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() { + self.move_settings_selection(-1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() { + self.move_settings_selection(1); + outcome.repaint = true; + return true; + } + if matches!(code, KeyCode::Enter | KeyCode::Char(' ')) && modifiers.is_empty() { + self.apply_settings_choice(outcome); + return true; + } + true + } +} diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs new file mode 100644 index 00000000..ea59ad02 --- /dev/null +++ b/src/client/shell/settings_overlay.rs @@ -0,0 +1,414 @@ +use super::*; + +fn choice_style(selected: bool, palette: &Palette) -> Style { + if selected { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text).bg(palette.panel_bg) + } +} + +fn draw_choice( + buffer: &mut Buffer, + rect: Rect, + label: &str, + selected: bool, + current: bool, + palette: &Palette, +) { + let style = choice_style(selected, palette); + buffer.set_style(rect, style); + let marker = if selected { "▸" } else { " " }; + let current = if current { " ✓" } else { "" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!(" {marker} {label}{current}"), + style, + ); +} + +pub(super) fn render_settings_overlay( + buffer: &mut Buffer, + settings: &ClientSettingsOverlay, + integration_updates_available: bool, + palette: &Palette, +) -> Option { + let integration_height = 14u16 + .saturating_add(settings.integrations.len().max(1) as u16) + .saturating_add(settings.integration_messages.len().min(6) as u16); + let height = if settings.section == ClientSettingsSection::Integrations { + integration_height.max(22) + } else { + 22 + }; + let popup = popup(buffer.area, 76, height)?; + let inner = panel(buffer, popup, palette.accent, palette.panel_bg)?; + if inner.width < 20 || inner.height < 8 { + return None; + } + + put_text( + buffer, + inner.x, + inner.y, + inner.width, + " settings", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + + let integration_badge = integration_updates_available + || settings + .integrations + .iter() + .any(|integration| integration.state == crate::api::schema::IntegrationState::Outdated); + let mut tab_x = inner.x; + let mut tab_hits = Vec::new(); + for section in ClientSettingsSection::ALL { + let badge = *section == ClientSettingsSection::Integrations && integration_badge; + let label = if badge { + format!(" ● {} ", section.label()) + } else { + format!(" {} ", section.label()) + }; + let width = display_width(&label).min(inner.right().saturating_sub(tab_x)); + let rect = Rect::new(tab_x, inner.y + 1, width, 1); + let active = *section == settings.section; + let style = if active { + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + buffer.set_style(rect, style); + put_text(buffer, rect.x, rect.y, rect.width, &label, style); + if badge && !active { + put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(1).min(2), + "● ", + Style::default() + .fg(palette.accent) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + } + tab_hits.push((rect, *section)); + tab_x = tab_x.saturating_add(width.saturating_add(1)); + if tab_x >= inner.right() { + break; + } + } + put_text( + buffer, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(palette.surface0).bg(palette.panel_bg), + ); + + let content = Rect::new( + inner.x, + inner.y + 4, + inner.width, + inner.height.saturating_sub(7), + ); + let mut choice_hits = Vec::new(); + match settings.section { + ClientSettingsSection::Theme => { + let visible = usize::from(content.height); + let scroll = settings.selected.saturating_sub(visible.saturating_sub(1)); + for (visible_index, (index, name)) in crate::config::THEME_NAMES + .iter() + .enumerate() + .skip(scroll) + .take(visible) + .enumerate() + { + let rect = Rect::new( + content.x, + content.y + visible_index as u16, + content.width, + 1, + ); + draw_choice( + buffer, + rect, + name, + index == settings.selected, + super::super::settings::normalized_theme_name(name) + == super::super::settings::normalized_theme_name( + &settings.original_theme_name, + ), + palette, + ); + choice_hits.push((rect, index)); + } + } + ClientSettingsSection::Indicators => { + render_choice_section( + buffer, + content, + "agent status indicators", + "choose color dots or distinct symbols for each state", + &["color dots ● ● ● ○ ·", "distinct symbols × ◐ ✓ ○ ·"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Sound => { + render_choice_section( + buffer, + content, + "sound alerts", + "play sounds when agents change state in background", + &["on", "off"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Toast => { + render_choice_section( + buffer, + content, + "notification popups", + "choose where background popup notifications should appear", + &["off", "inside herdr", "via terminal", "via system"], + settings.selected, + palette, + &mut choice_hits, + ); + } + ClientSettingsSection::Integrations => { + render_integrations(buffer, content, settings, palette); + } + } + + let installable = settings + .integrations + .iter() + .any(super::super::settings::integration_needs_install); + let show_primary = settings.section != ClientSettingsSection::Integrations || installable; + let labels = if show_primary { vec![10, 12] } else { vec![12] }; + let buttons = row(inner, &labels, 2, inner.height.saturating_sub(1)); + let (primary, close) = if show_primary { + let primary = buttons[0]; + button( + buffer, + primary, + if settings.section == ClientSettingsSection::Integrations { + " ↵ install " + } else { + " ↵ apply " + }, + Style::default() + .fg(contrast(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + (primary, buttons[1]) + } else { + (Rect::default(), buttons[0]) + }; + button( + buffer, + close, + " esc close ", + Style::default() + .fg(palette.text) + .bg(palette.surface0) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + inner.x, + inner.bottom().saturating_sub(2), + inner.width, + " ↑↓ select tab section", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + + Some(OverlayRender { + primary, + cancel: close, + settings_popup: popup, + settings_tabs: tab_hits, + settings_choices: choice_hits, + ..OverlayRender::default() + }) +} + +fn render_choice_section( + buffer: &mut Buffer, + area: Rect, + title: &str, + description: &str, + choices: &[&str], + selected: usize, + palette: &Palette, + hits: &mut Vec<(Rect, usize)>, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + title, + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + description, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + let row_gap = u16::from(choices.len() > 2); + for (index, choice) in choices.iter().enumerate() { + let y = area.y + 3 + index as u16 * (1 + row_gap); + if y >= area.bottom() { + break; + } + let rect = Rect::new(area.x, y, area.width, 1); + draw_choice(buffer, rect, choice, index == selected, false, palette); + hits.push((rect, index)); + } +} + +fn render_integrations( + buffer: &mut Buffer, + area: Rect, + settings: &ClientSettingsOverlay, + palette: &Palette, +) { + put_text( + buffer, + area.x, + area.y, + area.width, + "agent integrations", + Style::default() + .fg(palette.text) + .bg(palette.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + area.x, + area.y + 1, + area.width, + "let agents report state directly instead of relying only on process detection", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + if settings.loading_integrations { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " loading integrations…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + if settings.integrations.is_empty() { + put_text( + buffer, + area.x, + area.y + 3, + area.width, + " no integration targets available", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + return; + } + for (index, integration) in settings.integrations.iter().enumerate() { + let y = area.y + 3 + index as u16; + if y >= area.bottom() { + break; + } + let (marker, color, status) = match integration.state { + crate::api::schema::IntegrationState::Current => ("✓", palette.green, "installed"), + crate::api::schema::IntegrationState::Outdated => { + ("↻", palette.yellow, "update available") + } + crate::api::schema::IntegrationState::NotInstalled if integration.available => { + ("+", palette.accent, "available") + } + crate::api::schema::IntegrationState::NotInstalled => { + ("–", palette.overlay0, "not found") + } + }; + put_text( + buffer, + area.x, + y, + 3, + &format!(" {marker}"), + Style::default().fg(color).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 3, + y, + 11.min(area.width.saturating_sub(3)), + &format!("{:<9}", integration.label), + Style::default().fg(palette.subtext0).bg(palette.panel_bg), + ); + put_text( + buffer, + area.x + 14, + y, + area.width.saturating_sub(14), + status, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + let message_y = area + .y + .saturating_add(4) + .saturating_add(settings.integrations.len() as u16); + for (offset, message) in settings.integration_messages.iter().take(6).enumerate() { + let y = message_y.saturating_add(offset as u16); + if y >= area.bottom() { + break; + } + put_text( + buffer, + area.x, + y, + area.width, + &format!(" {message}"), + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + if settings.installing_integrations && message_y < area.bottom() { + put_text( + buffer, + area.x, + message_y, + area.width, + " installing…", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } +} diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs new file mode 100644 index 00000000..f8a87043 --- /dev/null +++ b/src/client/shell/sidebar.rs @@ -0,0 +1,698 @@ +use super::*; +use ratatui::{ + text::Line, + widgets::{Paragraph, Widget}, +}; + +fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option, Rect) { + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + if content.is_empty() { + return (Rect::default(), None, Rect::default()); + } + if content.height < 7 { + return (content, None, Rect::default()); + } + let workspace_height = content.height.div_ceil(2); + let divider_y = content.y + workspace_height; + let detail_height = content.height.saturating_sub(workspace_height + 1); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Some(divider_y), + Rect::new(content.x, divider_y + 1, content.width, detail_height), + ) +} + +pub(crate) fn render_collapsed_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + selected_workspace_id: Option<&str>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area); + for (index, workspace) in snapshot + .workspaces + .iter() + .take(workspace_area.height as usize) + .enumerate() + { + let rect = Rect::new( + workspace_area.x, + workspace_area.y + index as u16, + workspace_area.width, + 1, + ); + let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); + let selection_background = + if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + palette.selection_bg + }; + if selected { + buffer.set_style(rect, Style::default().bg(selection_background)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let number_style = if selected { + Style::default() + .fg(palette.overlay1) + .bg(selection_background) + } else if workspace.focused { + Style::default().fg(palette.text).bg(palette.active_row_bg) + } else { + Style::default().fg(palette.overlay0) + }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + number_style, + ); + let status = workspace.agent_status; + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(status, config.status_indicators), + Style::default().fg(status_color(status, palette)), + ); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + } + + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + + let detail_content = Rect::new( + detail_area.x, + detail_area.y, + detail_area.width, + detail_area.height.saturating_sub(1), + ); + for (index, pane_id) in super::ordered_agent_pane_ids(snapshot, config.agent_panel_sort) + .into_iter() + .take(detail_content.height as usize) + .enumerate() + { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + else { + continue; + }; + let rect = Rect::new( + detail_content.x, + detail_content.y + index as u16, + detail_content.width, + 1, + ); + if agent.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + put_text( + buffer, + rect.x, + rect.y, + rect.width.min(2), + &format!("{:<2}", index + 1), + Style::default().fg(if agent.focused { + palette.text + } else { + palette.overlay0 + }), + ); + put_text( + buffer, + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + status_icon(agent.agent_status, config.status_indicators), + Style::default().fg(status_color(agent.agent_status, palette)), + ); + hits.agents.push((rect, pane_id)); + } + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + if super::super::global_menu::global_menu_attention(snapshot) { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay0) + }, + ); +} + +pub(crate) fn render_sidebar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " spaces", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + let entries = workspace_entries(snapshot, state.collapsed_groups); + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = entries + .iter() + .map(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| { + workspace_rows( + workspace, + displayed_workspace_status(snapshot, workspace, state.collapsed_groups), + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1) + }) + .collect::>(); + let gaps = entries + .iter() + .enumerate() + .map(|(index, _)| { + entries + .get(index + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap) + }) + .collect::>(); + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for (entry_position, entry) in entries.iter().enumerate().skip(*state.workspace_scroll) { + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let status = displayed_workspace_status(snapshot, workspace, state.collapsed_groups); + let rows = workspace_rows(workspace, status, entry.indented, &config.spaces); + let row_height = (rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(row_height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, row_height); + let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str()); + let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str()); + if selected { + buffer.set_style(rect, Style::default().bg(palette.selection_bg)); + } else if dragged { + buffer.set_style(rect, Style::default().bg(palette.surface1)); + } else if workspace.focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + render_workspace_rows( + buffer, + rect, + workspace, + status, + config.status_indicators, + entry, + rows, + selected, + dragged, + palette, + ); + let group_toggle = parent_group_key(snapshot, entry.index).map(|key| { + let rect = Rect::new(rect.right().saturating_sub(1), rect.y, 1, 1); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + if state.collapsed_groups.contains(&key) { + "▸" + } else { + "▾" + }, + Style::default().fg(palette.accent), + ); + (rect, key) + }); + hits.workspaces.push(WorkspaceHit { + rect, + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle, + }); + let gap = entries + .get(entry_position + 1) + .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap); + y = y.saturating_add(row_height + gap); + } + + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + if let Some(row) = state.workspace_drop_indicator_row.filter(|row| { + *row >= workspace_area.y.saturating_add(1) + && *row < workspace_area.bottom().saturating_sub(1) + }) { + put_text( + buffer, + body.x, + row, + body.width, + &"─".repeat(body.width as usize), + Style::default().fg(palette.accent), + ); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + 5.min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + " new", + Style::default().fg(palette.overlay0), + ); + let attention = super::super::global_menu::global_menu_attention(snapshot); + let launcher_width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(launcher_width), + footer_y, + launcher_width, + 1, + ); + if attention { + let start_x = workspace_area.right().saturating_sub(6); + put_text( + buffer, + start_x, + footer_y, + 2, + "● ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ); + put_text( + buffer, + start_x.saturating_add(2), + footer_y, + 4, + "menu", + Style::default().fg(palette.overlay0), + ); + } else { + put_right_text( + buffer, + workspace_area, + footer_y, + "menu", + Style::default().fg(palette.overlay0), + ); + } + } + + super::render_agent_panel( + buffer, + detail_area, + snapshot, + config, + state.agent_scroll, + hits, + ); + + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + +pub(crate) fn workspace_entries( + snapshot: &ClientShellSnapshot, + collapsed_groups: &HashSet, +) -> Vec { + let mut members = HashMap::<&str, Vec>::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + if let Some(worktree) = &workspace.worktree { + members.entry(&worktree.key).or_default().push(index); + } + } + let grouped = members + .iter() + .filter(|(_, indices)| { + indices.len() >= 2 + && indices.iter().any(|index| { + snapshot.workspaces[*index] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + }) + .map(|(key, _)| *key) + .collect::>(); + let mut emitted = HashSet::<&str>::new(); + let mut entries = Vec::new(); + for (index, workspace) in snapshot.workspaces.iter().enumerate() { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| grouped.contains(worktree.key.as_str())) + else { + entries.push(WorkspaceEntry { + index, + indented: false, + last_child: false, + }); + continue; + }; + if !emitted.insert(&worktree.key) { + continue; + } + let Some(group_members) = members.get(worktree.key.as_str()) else { + continue; + }; + let parent = group_members + .iter() + .copied() + .find(|member| { + snapshot.workspaces[*member] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) + .unwrap_or(index); + entries.push(WorkspaceEntry { + index: parent, + indented: false, + last_child: false, + }); + if collapsed_groups.contains(&worktree.key) { + if let Some(active) = group_members + .iter() + .copied() + .find(|member| *member != parent && snapshot.workspaces[*member].focused) + { + entries.push(WorkspaceEntry { + index: active, + indented: true, + last_child: true, + }); + } + continue; + } + let children = group_members + .iter() + .copied() + .filter(|member| *member != parent) + .collect::>(); + for (child_index, child) in children.iter().enumerate() { + entries.push(WorkspaceEntry { + index: *child, + indented: true, + last_child: child_index + 1 == children.len(), + }); + } + } + entries +} + +fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option { + let workspace = snapshot.workspaces.get(index)?; + let worktree = workspace.worktree.as_ref()?; + if worktree.is_linked_worktree { + return None; + } + (snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .count() + >= 2) + .then(|| worktree.key.clone()) +} + +fn displayed_workspace_status( + snapshot: &ClientShellSnapshot, + workspace: &ClientShellWorkspace, + collapsed_groups: &HashSet, +) -> crate::api::schema::AgentStatus { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + else { + return workspace.agent_status; + }; + if !collapsed_groups.contains(&worktree.key) { + return workspace.agent_status; + } + snapshot + .workspaces + .iter() + .filter(|candidate| { + candidate + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|candidate| candidate.agent_status) + .max_by_key(|status| status_priority(*status)) + .unwrap_or(workspace.agent_status) +} + +fn workspace_rows( + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indented: bool, + config: &SpacesSidebarConfig, +) -> Vec> { + let label = if indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let token_values = workspace.tokens.iter().cloned().collect::>(); + crate::ui::sidebar_space_rows( + config, + crate::ui::SpaceTokenContext { + workspace: label, + branch: workspace.branch.as_deref(), + state_text: status_text(status), + ahead_behind: workspace.git_ahead_behind, + tokens: &token_values, + suppress_git_details: indented, + }, + ) +} + +fn render_workspace_rows( + buffer: &mut Buffer, + area: Rect, + workspace: &ClientShellWorkspace, + status: crate::api::schema::AgentStatus, + indicators: crate::config::StatusIndicatorStyle, + entry: &WorkspaceEntry, + rows: Vec>, + selected: bool, + dragged: bool, + palette: &Palette, +) { + for (row_index, row) in rows.iter().enumerate() { + let y = area.y + row_index as u16; + if y >= area.bottom() { + break; + } + let mut x = area.x; + if entry.indented { + let prefix = if row_index == 0 { + if entry.last_child { + " └─ " + } else { + " ├─ " + } + } else if entry.last_child { + " " + } else { + " │ " + }; + x = put_segment( + buffer, + x, + y, + area.right(), + prefix, + Style::default().fg(palette.overlay0), + ); + } else if row_index == 0 { + x = x.saturating_add(1); + } else { + x = x.saturating_add(3); + } + let highlighted = workspace.focused || dragged; + let workspace_style = Style::default() + .fg(if highlighted { + palette.text + } else { + palette.subtext0 + }) + .add_modifier(if highlighted { + Modifier::BOLD + } else { + Modifier::empty() + }); + let secondary_style = Style::default().fg(if workspace.focused { + palette.mauve + } else { + palette.overlay0 + }); + let spans = crate::ui::resolved_token_spans( + row, + ( + status_icon(status, indicators), + Style::default().fg(status_color(status, palette)), + ), + Style::default() + .fg(status_color(status, palette)) + .add_modifier(Modifier::DIM), + workspace_style, + secondary_style, + Style::default().fg(palette.overlay1), + palette, + area.right().saturating_sub(2).saturating_sub(x) as usize, + ); + Paragraph::new(Line::from(spans)).render( + Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1), + buffer, + ); + } + + let background = if selected { + Some(palette.selection_bg) + } else if dragged { + Some(palette.surface1) + } else if workspace.focused { + Some(palette.active_row_bg) + } else { + None + }; + if let Some(background) = background { + for y in area.y..area.bottom() { + for x in area.x..area.right() { + buffer[(x, y)].set_bg(background); + } + } + } +} diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs new file mode 100644 index 00000000..81173c18 --- /dev/null +++ b/src/client/shell/state.rs @@ -0,0 +1,1613 @@ +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; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClientShellKeybindingSource { + Local, + RemoteLocal, + Endpoint, +} + +pub(crate) struct ClientShellConfig { + pub(super) sidebar_width: u16, + pub(super) sidebar_min_width: u16, + pub(super) sidebar_max_width: u16, + pub(super) sidebar_start_collapsed: bool, + pub(super) sidebar_collapsed_mode: SidebarCollapsedModeConfig, + pub(super) mobile_width_threshold: u16, + pub(super) tab_bar_position: TabBarPositionConfig, + pub(super) hide_tab_bar_when_single_tab: bool, + pub(super) spaces: SpacesSidebarConfig, + pub(super) agents: crate::config::AgentsSidebarConfig, + pub(super) agent_panel_sort: crate::config::AgentPanelSortConfig, + pub(super) status_indicators: crate::config::StatusIndicatorStyle, + pub(super) sound_enabled: bool, + pub(super) toast_delivery: crate::config::ToastDelivery, + pub(super) toast_delay_seconds: u64, + pub(super) toast_position: crate::config::ToastHerdrPosition, + pub(super) copy_on_select: bool, + pub(super) clipboard_toast_enabled: bool, + pub(super) clipboard_toast_position: crate::config::ToastClipboardPosition, + pub(super) theme_name: String, + pub(super) theme_runtime: crate::app::state::ThemeRuntimeConfig, + pub(super) palette: Palette, + pub(super) keybinds: LiveKeybindConfig, + pub(super) local_keys: crate::config::KeysConfig, + pub(super) keybinding_source: ClientShellKeybindingSource, + pub(super) prompt_new_tab_name: bool, + pub(super) prompt_new_workspace_name: bool, + pub(super) confirm_close: bool, + pub(super) mouse_capture: bool, + pub(super) mouse_scroll_lines: usize, + pub(super) right_click_passthrough_modifiers: Option, + pub(super) redraw_on_focus_gained: bool, + pub(super) switch_ascii_input_source_in_prefix: bool, + pub(super) local_config_path: std::path::PathBuf, + pub(super) preferences_path: Option, + pub(super) preferences: preferences::ClientChromePreferences, + pub(super) startup_config_diagnostic: Option, + pub(super) startup_onboarding: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClientShellLayout { + pub sidebar: Rect, + pub tab_bar: Rect, + pub mobile_header: Rect, + pub pane_surface: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ClientMobileTarget { + NewWorkspace, + Workspace(String), + NewTab, + Tab(String), + Agent(String), + Menu(usize), +} + +#[derive(Default)] +pub(super) struct ShellHitMap { + pub(super) workspaces: Vec, + pub(super) workspace_body: Rect, + pub(super) workspace_scrollbar: Rect, + pub(super) workspace_scroll_metrics: Option, + pub(super) workspace_max_scroll: usize, + pub(super) tabs: Vec<(Rect, String)>, + pub(super) panes: Vec, + pub(super) popup: Option, + pub(super) pane_splits: Vec, + pub(super) agents: Vec<(Rect, String)>, + pub(super) agent_body: Rect, + pub(super) agent_scrollbar: Rect, + pub(super) agent_scroll_metrics: Option, + pub(super) agent_max_scroll: usize, + pub(super) agent_sort_toggle: Rect, + pub(super) sidebar_divider: Rect, + pub(super) sidebar_section_divider: Rect, + pub(super) sidebar_toggle: Rect, + pub(super) new_workspace: Rect, + pub(super) new_tab: Rect, + pub(super) tab_scroll_left: Rect, + pub(super) tab_scroll_right: Rect, + pub(super) mobile_switch: Rect, + pub(super) mobile_close: Rect, + pub(super) mobile_targets: Vec<(Rect, ClientMobileTarget)>, + pub(super) mobile_max_scroll: usize, + pub(super) global_launcher: Rect, + pub(super) notification_toast: Rect, + pub(super) global_menu_rows: Vec<(Rect, usize)>, + pub(super) context_menu_rows: Vec<(Rect, usize)>, + pub(super) overlay_primary: Rect, + pub(super) overlay_clear: Rect, + pub(super) overlay_cancel: Rect, + pub(super) navigator_popup: Rect, + pub(super) navigator_search: Rect, + pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) worktree_search: Rect, + pub(super) worktree_rows: Vec<(Rect, usize)>, + pub(super) help_popup: Rect, + pub(super) help_scrollbar: Rect, + pub(super) help_scroll_metrics: Option, + pub(super) help_max_scroll: usize, + pub(super) settings_popup: Rect, + pub(super) settings_tabs: Vec<(Rect, ClientSettingsSection)>, + pub(super) settings_choices: Vec<(Rect, usize)>, + pub(super) product_announcement_scrollbar: Rect, + pub(super) product_announcement_scroll_metrics: Option, + pub(super) product_announcement_max_scroll: usize, + pub(super) release_notes_scrollbar: Rect, + pub(super) release_notes_scroll_metrics: Option, + pub(super) release_notes_max_scroll: usize, +} + +#[derive(Clone)] +pub(super) struct PaneHit { + pub(super) rect: Rect, + pub(super) inner_rect: Rect, + pub(super) scrollbar_rect: Option, + pub(super) scroll: Option, + pub(super) pane_id: String, + pub(super) popup: bool, + pub(super) mouse_reporting: bool, + pub(super) sgr_pixel_mouse: bool, + pub(super) pixel_width: u32, + pub(super) pixel_height: u32, +} + +#[derive(Clone)] +pub(super) struct PaneSplitHit { + pub(super) direction: crate::protocol::PaneSurfaceSplitDirection, + pub(super) pos: u16, + pub(super) area: Rect, + pub(super) hit_rect: Rect, + pub(super) path: Vec, + pub(super) topology_signature: u64, +} + +pub(super) struct ClientPaneMouseGesture { + pub(super) hit: PaneHit, + pub(super) button: crossterm::event::MouseButton, + pub(super) stripped_modifiers: crossterm::event::KeyModifiers, + pub(super) last_event: crossterm::event::MouseEvent, + pub(super) last_position: crate::protocol::ClientMousePosition, +} + +pub(super) struct ClientWorkspacePress { + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) struct ClientTabPress { + pub(super) tab_id: String, + pub(super) workspace_id: String, + pub(super) start_column: u16, + pub(super) start_row: u16, +} + +pub(super) enum ClientChromeDrag { + SidebarWidth, + SidebarSection, + WorkspaceScrollbar { + grab_row_offset: u16, + }, + AgentScrollbar { + grab_row_offset: u16, + }, + HelpScrollbar { + grab_row_offset: u16, + }, + ProductAnnouncementScrollbar { + grab_row_offset: u16, + }, + ReleaseNotesScrollbar { + grab_row_offset: u16, + }, + Tab { + tab_id: String, + workspace_id: String, + insert_index: Option, + }, + Workspace { + source_workspace_id: String, + target: Option<(Option, u16)>, + }, + PaneSplit { + hit: PaneSplitHit, + tab_id: String, + grab_offset: i32, + last_sent_ratio: Option, + last_sent_at: Option, + }, + PaneScrollbar { + hit: PaneHit, + grab_row_offset: u16, + last_sent_offset: Option, + last_sent_at: Option, + }, +} + +pub(super) struct WorkspaceHit { + pub(super) rect: Rect, + pub(super) workspace_id: String, + pub(super) indented: bool, + pub(super) group_toggle: Option<(Rect, String)>, +} + +#[derive(Debug)] +pub(crate) enum ClientShellAction { + Endpoint { + boot_id: String, + request: Box, + }, + ClipboardWrite(Vec), + Request(ClientMessage), + OpenSafeWebUrl(String), + ReplayMouse(Vec), + Keybind(crate::input::KeybindAction), +} + +#[derive(Default)] +pub(crate) struct ClientShellInput { + pub detach: bool, + pub repaint: bool, + pub resize: bool, + pub query_host_appearance: bool, + pub query_host_theme: bool, + pub requests: Vec, + pub actions: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellMode { + Terminal, + Prefix, + Navigate, + Resize, + Copy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientShellOverlayKind { + Onboarding, + ProductAnnouncement, + ReleaseNotes, + Rename, + ConfirmClose, + Help, + Navigator, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove, + ContextMenu, + GlobalMenu, + Settings, +} + +#[derive(Debug)] +pub(super) enum ClientRenameTarget { + NewWorkspace { + source_workspace_id: Option, + cwd: Option, + suggested_name: String, + }, + Workspace { + workspace_id: String, + }, + NewTab { + workspace_id: String, + default_name: String, + }, + Tab { + tab_id: String, + auto_name: bool, + original_name: String, + }, + Pane { + pane_id: String, + }, +} + +#[derive(Debug)] +pub(super) struct ClientRenameOverlay { + pub(super) title: &'static str, + pub(super) input: String, + pub(super) replace_on_type: bool, + pub(super) target: ClientRenameTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ClientNavigatorFilter { + Blocked, + Working, + Idle, + Done, +} + +#[derive(Clone, Debug)] +pub(super) enum ClientNavigatorTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +#[derive(Clone, Debug)] +pub(super) struct ClientNavigatorRow { + pub(super) depth: u8, + pub(super) label: String, + pub(super) meta: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) current: bool, + pub(super) target: ClientNavigatorTarget, +} + +#[derive(Debug)] +pub(super) struct ClientNavigatorOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) selected: usize, + pub(super) scroll: usize, + pub(super) filter: Option, + pub(super) expanded_workspaces: HashSet, +} + +#[derive(Debug)] +pub(super) struct ClientHelpOverlay { + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) scroll: usize, +} + +#[derive(Debug)] +pub(super) struct ClientGlobalMenuOverlay { + pub(super) highlighted: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSettingsSection { + Theme, + Indicators, + Sound, + Toast, + Integrations, +} + +impl ClientSettingsSection { + pub(super) const ALL: &[Self] = &[ + Self::Theme, + Self::Indicators, + Self::Sound, + Self::Toast, + Self::Integrations, + ]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Theme => "theme", + Self::Indicators => "indicators", + Self::Sound => "sound", + Self::Toast => "toasts", + Self::Integrations => "integrations", + } + } +} + +#[derive(Debug)] +pub(super) struct ClientSettingsOverlay { + pub(super) section: ClientSettingsSection, + pub(super) selected: usize, + pub(super) original_theme_name: String, + pub(super) original_palette: Palette, + pub(super) integrations: Vec, + pub(super) integration_messages: Vec, + pub(super) loading_integrations: bool, + pub(super) installing_integrations: bool, +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeCreateOverlay { + pub(super) source_workspace_id: String, + pub(super) repo_name: String, + pub(super) branch: String, + pub(super) checkout_path: String, + pub(super) replace_on_type: bool, + pub(super) error: Option, + pub(super) creating: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ClientWorktreeOpenEntry { + pub(super) path: String, + pub(super) branch: Option, + pub(super) is_linked_worktree: bool, + pub(super) is_detached: bool, + pub(super) open_workspace_id: Option, + pub(super) label: String, +} + +impl ClientWorktreeOpenEntry { + pub(super) fn status_label(&self) -> &'static str { + if self.open_workspace_id.is_some() { + "open" + } else if self.branch.is_some() { + "" + } else if self.is_detached && self.is_linked_worktree { + "detached" + } else { + "root" + } + } + + pub(super) fn matches_query(&self, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || format!( + "{} {} {} {}", + self.label, + self.branch.as_deref().unwrap_or_default(), + self.path, + self.status_label() + ) + .to_lowercase() + .contains(&query) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeOpenOverlay { + pub(super) source_workspace_id: String, + pub(super) entries: Vec, + pub(super) selected: usize, + pub(super) query: String, + pub(super) search_focused: bool, + pub(super) error: Option, + pub(super) opening: bool, +} + +impl ClientWorktreeOpenOverlay { + pub(super) fn filtered_indices(&self) -> Vec { + self.entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.matches_query(&self.query).then_some(index)) + .collect() + } + + pub(super) fn selected_entry_index(&self) -> Option { + let filtered = self.filtered_indices(); + filtered + .contains(&self.selected) + .then_some(self.selected) + .or_else(|| filtered.first().copied()) + } +} + +#[derive(Debug)] +pub(super) struct ClientWorktreeRemoveOverlay { + pub(super) workspace_id: String, + pub(super) path: String, + pub(super) error: Option, + pub(super) removing: bool, + pub(super) force_confirmation: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientContextMenuAction { + Rename, + Close, + NewWorktree, + OpenWorktree, + RemoveWorktree, + ToggleGroup, + NewTab, + RenamePane, + ClearPaneName, + SwapWithFocusedPane, + SplitRight, + SplitDown, + Zoom, + ToggleRightClickPassthrough, + ClosePane, +} + +#[derive(Debug)] +pub(super) enum ClientContextMenuTarget { + Workspace { + workspace_id: String, + is_git: bool, + is_linked_worktree: bool, + has_worktree_children: bool, + collapsed: bool, + }, + Tab { + tab_id: String, + workspace_id: String, + }, + Pane { + pane_id: String, + workspace_id: String, + source_pane_id: Option, + has_manual_label: bool, + right_click_passthrough: bool, + }, +} + +#[derive(Debug)] +pub(super) struct ClientContextMenuOverlay { + pub(super) target: ClientContextMenuTarget, + pub(super) x: u16, + pub(super) y: u16, + pub(super) highlighted: usize, +} + +pub(super) struct ClientContextMenuItem { + pub(super) label: &'static str, + pub(super) action: ClientContextMenuAction, +} + +#[derive(Debug)] +pub(super) struct ClientConfirmCloseOverlay { + pub(super) workspace_id: String, + pub(super) title: String, + pub(super) detail: String, +} + +#[derive(Debug)] +pub(super) enum ClientShellOverlay { + Onboarding, + ProductAnnouncement(crate::app::state::ProductAnnouncementState), + ReleaseNotes(crate::app::state::ReleaseNotesState), + Rename(ClientRenameOverlay), + ConfirmClose(ClientConfirmCloseOverlay), + Help(ClientHelpOverlay), + Navigator(ClientNavigatorOverlay), + WorktreeCreate(ClientWorktreeCreateOverlay), + WorktreeOpen(ClientWorktreeOpenOverlay), + WorktreeRemove(ClientWorktreeRemoveOverlay), + ContextMenu(ClientContextMenuOverlay), + GlobalMenu(ClientGlobalMenuOverlay), + Settings(ClientSettingsOverlay), +} + +impl ClientShellOverlay { + pub(super) fn kind(&self) -> ClientShellOverlayKind { + match self { + Self::Onboarding => ClientShellOverlayKind::Onboarding, + Self::ProductAnnouncement(_) => ClientShellOverlayKind::ProductAnnouncement, + Self::ReleaseNotes(_) => ClientShellOverlayKind::ReleaseNotes, + Self::Rename(_) => ClientShellOverlayKind::Rename, + Self::ConfirmClose(_) => ClientShellOverlayKind::ConfirmClose, + Self::Help(_) => ClientShellOverlayKind::Help, + Self::Navigator(_) => ClientShellOverlayKind::Navigator, + Self::WorktreeCreate(_) => ClientShellOverlayKind::WorktreeCreate, + Self::WorktreeOpen(_) => ClientShellOverlayKind::WorktreeOpen, + Self::WorktreeRemove(_) => ClientShellOverlayKind::WorktreeRemove, + Self::ContextMenu(_) => ClientShellOverlayKind::ContextMenu, + Self::GlobalMenu(_) => ClientShellOverlayKind::GlobalMenu, + Self::Settings(_) => ClientShellOverlayKind::Settings, + } + } +} + +#[derive(Debug)] +pub(super) enum PendingEndpointKind { + Generic, + ProductAnnouncementDismiss { + version: String, + id: String, + }, + ReleaseNotesDismiss, + PopupCommand, + ReloadConfig, + IntegrationList, + IntegrationInstall, + PrepareWorktreeCreate { + workspace_id: String, + }, + PrepareWorktreeOpen { + workspace_id: String, + }, + PrepareWorktreeRemove { + workspace_id: String, + }, + WorktreeCreate, + WorktreeOpen, + WorktreeRemove { + forced: bool, + }, + SelectionCopy { + fallback: Option, + }, + PaneScroll { + pane_id: String, + serial: u64, + }, + WordSelection { + pane_id: String, + absolute_row: u32, + col: u16, + generation: u64, + }, + PaneLinkActivate { + pane_id: String, + inner_rect: Rect, + fallback_events: Vec, + }, + CopyMotion { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + session_generation: u64, + }, + CopySearch { + pane_id: String, + origin: crate::api::schema::PaneTextPoint, + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + generation: u64, + session_generation: u64, + }, +} + +pub(super) struct PendingEndpointRequest { + pub(super) boot_id: String, + pub(super) confirmation_workspace_id: Option, + pub(super) kind: PendingEndpointKind, +} + +pub(crate) struct ClientShellEndpointError { + pub code: Option, + pub message: String, +} + +pub(crate) enum ClientShellNotificationEffect { + Sound { + sound: crate::sound::Sound, + agent: Option, + }, + Terminal { + title: String, + body: Option, + }, + System { + title: String, + body: Option, + }, +} + +pub(super) struct ClientPendingNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, + pub(super) validate_state: bool, +} + +pub(super) struct ClientVisibleNotification { + pub(super) event: SemanticNotification, + pub(super) deadline: std::time::Instant, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientInputContext { + pub(super) mode: ClientShellMode, + pub(super) overlay: Option, + pub(super) popup_terminal_id: Option, + pub(super) popup_pending: bool, + pub(super) retained_selection: bool, +} + +type ClientInputLeases = crate::input::InputLeaseTable; + +#[derive(Clone, Debug)] +pub(super) struct ClientPaneClick { + pub(super) pane_id: String, + pub(super) viewport_row: u16, + pub(super) col: u16, + pub(super) at: std::time::Instant, +} + +impl ClientPaneClick { + pub(super) fn is_double_click_for(&self, next: &Self) -> bool { + self.pane_id == next.pane_id + && next.at.duration_since(self.at) <= std::time::Duration::from_millis(350) + && self.viewport_row.abs_diff(next.viewport_row) <= 1 + && self.col.abs_diff(next.col) <= 1 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientSelectionAutoscrollDirection { + Up, + Down, +} + +#[derive(Clone, Debug)] +pub(super) struct ClientSelectionAutoscroll { + pub(super) pane_id: String, + pub(super) direction: ClientSelectionAutoscrollDirection, + pub(super) last_mouse_column: u16, + pub(super) last_mouse_row: u16, + pub(super) inner_rect: Rect, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ClientCopySelection { + Character { + anchor: crate::api::schema::PaneTextPoint, + }, + Linewise { + anchor_row: u32, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopySearchPrompt { + pub(super) direction: crate::api::schema::PaneCopySearchDirection, + pub(super) query: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ClientCopyOperation { + Motion(crate::api::schema::PaneCopyMotion), + Search { + query: String, + direction: crate::api::schema::PaneCopySearchDirection, + repeat: bool, + }, +} + +pub(super) struct ClientCopySearchResult { + pub(super) content_revision: u64, + pub(super) matches: Vec, + pub(super) total: u64, + pub(super) current: Option, + pub(super) current_global: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ClientCopyModeState { + pub(super) pane_id: String, + pub(super) content_revision: u64, + pub(super) geometry: (u16, u16), + pub(super) cursor: crate::api::schema::PaneTextPoint, + pub(super) offset_from_bottom: usize, + pub(super) max_offset_from_bottom: usize, + pub(super) entry_offset_from_bottom: usize, + pub(super) selection: Option, + pub(super) search_prompt: Option, + pub(super) search_query: String, + pub(super) search_direction: Option, + pub(super) search_matches: Vec, + pub(super) search_total: u64, + pub(super) search_current: Option, + pub(super) search_current_global: Option, + pub(super) search_generation: u64, + pub(super) copy_after_search: bool, +} + +pub(crate) struct ClientShellState { + pub(super) config: ClientShellConfig, + pub(super) snapshot: Option>, + pub(super) pane_surface: Option, + pub(super) graphics: crate::kitty_graphics::surface::ClientState, + pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, + pub(super) popup_terminal_id: Option, + pub(super) sidebar_collapsed: bool, + pub(super) sidebar_collapsed_manual: bool, + pub(super) sidebar_width: u16, + pub(super) sidebar_width_manual: bool, + pub(super) sidebar_section_split: f32, + pub(super) sidebar_section_split_manual: bool, + pub(super) agent_panel_sort_manual: bool, + pub(super) last_sidebar_divider_click: Option, + pub(super) chrome_drag: Option, + pub(super) workspace_press: Option, + pub(super) tab_press: Option, + pub(super) collapsed_groups: HashSet, + pub(super) workspace_scroll: usize, + pub(super) agent_scroll: usize, + pub(super) tab_scroll: usize, + pub(super) mobile_switcher_scroll: usize, + pub(super) reveal_mobile_workspace: bool, + pub(super) mobile_switcher_suspended: bool, + pub(super) reveal_focused_tab: bool, + pub(super) last_tab_bar_width: Option, + pub(super) last_composed_size: Option<(u16, u16)>, + pub(super) hits: ShellHitMap, + pub(super) mode: ClientShellMode, + pub(super) navigate_workspace_id: Option, + pub(super) overlay: Option, + pub(super) previous_pane_id: Option, + pub(super) pane_mouse_gesture: Option, + pub(super) url_click_consumes_until_up: bool, + pub(super) replaying_url_click: bool, + pub(super) selection: Option>, + pub(super) last_pane_click: Option, + pub(super) selection_autoscroll: Option, + pub(super) selection_autoscroll_deadline: Option, + pub(super) selection_highlight_clear_deadline: Option, + pub(super) pending_word_selection: Option, + pub(super) word_selection_generation: u64, + pub(super) copy_mode: Option, + pub(super) copy_session_generation: u64, + pub(super) copy_operation_in_flight: bool, + pub(super) copy_operation_queue: VecDeque, + pub(super) copy_input_queue: VecDeque, + pub(super) next_scroll_serial: u64, + pub(super) pane_scroll_in_flight: HashMap, + pub(super) pane_scroll_queued: HashMap, + pub(super) pane_scroll_targets: HashMap, + pub(super) copy_feedback: Option, + pub(super) copy_feedback_deadline: Option, + pub(super) host_mouse_pixels: Option, + pub(super) input_leases: ClientInputLeases, + pub(super) popup_pending: bool, + pub(super) popup_pending_deadline: Option, + pub(super) next_request_id: u64, + pub(super) pending_requests: HashMap, + pub(super) pending_integration_installs: usize, + pub(super) pending_notifications: Vec, + pub(super) visible_notification: Option, + pub(super) outer_focused: Option, + pub(super) ascii_input_source_active: bool, + pub(super) pending_input_source_changes: Vec, + pub(super) host_appearance: Option, + pub(super) host_appearance_explicit: bool, + pub(super) local_config_diagnostic: Option, + pub(super) config_diagnostic: Option, + pub(super) endpoint_error: Option, + pub(super) dismissed_product_announcement: Option<(String, String)>, +} + +pub(super) fn product_announcement_state( + announcement: &crate::protocol::ClientShellProductAnnouncement, +) -> crate::app::state::ProductAnnouncementState { + crate::app::state::ProductAnnouncementState { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + scroll: 0, + preview: announcement.preview, + } +} + +pub(super) fn release_notes_state( + notes: &crate::protocol::ClientShellReleaseNotes, +) -> crate::app::state::ReleaseNotesState { + crate::app::state::ReleaseNotesState { + version: notes.version.clone(), + body: notes.body.clone(), + scroll: 0, + preview: notes.preview, + } +} + +#[derive(Clone, Copy)] +pub(super) struct WorkspaceEntry { + pub(super) index: usize, + pub(super) indented: bool, + pub(super) last_child: bool, +} + +impl ClientShellState { + pub(crate) fn new(mut config: ClientShellConfig) -> Self { + let preferences = config.preferences.clone(); + let local_config_diagnostic = config.startup_config_diagnostic.take(); + let overlay = config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + let sidebar_collapsed = preferences + .sidebar_collapsed + .unwrap_or(config.sidebar_start_collapsed); + let (min_width, max_width) = crate::config::validated_sidebar_bounds( + config.sidebar_min_width, + config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let sidebar_width = preferences + .sidebar_width + .unwrap_or(config.sidebar_width) + .clamp(min_width, max_width); + let sidebar_section_split = preferences + .sidebar_section_split + .filter(|split| split.is_finite()) + .map(|split| split.clamp(0.1, 0.9)) + .unwrap_or(0.5); + if let Some(sort) = preferences.agent_panel_sort { + config.agent_panel_sort = sort; + } + Self { + config, + snapshot: None, + pane_surface: None, + graphics: crate::kitty_graphics::surface::ClientState::default(), + graphics_cell_size: crate::kitty_graphics::HostCellSize { + width_px: 1, + height_px: 1, + }, + popup_terminal_id: None, + sidebar_collapsed, + sidebar_collapsed_manual: preferences.sidebar_collapsed.is_some(), + sidebar_width, + sidebar_width_manual: preferences.sidebar_width.is_some(), + sidebar_section_split, + sidebar_section_split_manual: preferences.sidebar_section_split.is_some(), + agent_panel_sort_manual: preferences.agent_panel_sort.is_some(), + last_sidebar_divider_click: None, + chrome_drag: None, + workspace_press: None, + tab_press: None, + collapsed_groups: preferences.collapsed_groups.into_iter().collect(), + workspace_scroll: 0, + agent_scroll: 0, + tab_scroll: 0, + mobile_switcher_scroll: 0, + reveal_mobile_workspace: false, + mobile_switcher_suspended: false, + reveal_focused_tab: true, + last_tab_bar_width: None, + last_composed_size: None, + hits: ShellHitMap::default(), + mode: ClientShellMode::Terminal, + navigate_workspace_id: None, + overlay, + previous_pane_id: None, + pane_mouse_gesture: None, + url_click_consumes_until_up: false, + replaying_url_click: false, + selection: None, + last_pane_click: None, + selection_autoscroll: None, + selection_autoscroll_deadline: None, + selection_highlight_clear_deadline: None, + pending_word_selection: None, + word_selection_generation: 0, + copy_mode: None, + copy_session_generation: 0, + copy_operation_in_flight: false, + copy_operation_queue: VecDeque::new(), + copy_input_queue: VecDeque::new(), + next_scroll_serial: 0, + pane_scroll_in_flight: HashMap::new(), + pane_scroll_queued: HashMap::new(), + pane_scroll_targets: HashMap::new(), + copy_feedback: None, + copy_feedback_deadline: None, + host_mouse_pixels: None, + input_leases: ClientInputLeases::default(), + popup_pending: false, + popup_pending_deadline: None, + next_request_id: 1, + pending_requests: HashMap::new(), + pending_integration_installs: 0, + pending_notifications: Vec::new(), + visible_notification: None, + outer_focused: None, + ascii_input_source_active: false, + pending_input_source_changes: Vec::new(), + host_appearance: None, + host_appearance_explicit: false, + config_diagnostic: local_config_diagnostic.clone(), + local_config_diagnostic, + endpoint_error: None, + dismissed_product_announcement: None, + } + } + + fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { + if !self.mobile_switcher_suspended || self.overlay.is_some() { + return false; + } + self.mobile_switcher_suspended = false; + if self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_ref()) + .is_some() + { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + } else { + self.mode = ClientShellMode::Navigate; + } + true + } + + pub(super) fn mobile_layout_active(&self) -> bool { + self.last_composed_size + .is_some_and(|(cols, rows)| !self.layout(cols, rows).mobile_header.is_empty()) + } + + pub(super) fn navigation_workspace_entries( + &self, + snapshot: &ClientShellSnapshot, + ) -> Vec { + if self.mobile_layout_active() { + render::workspace_entries(snapshot, &HashSet::new()) + } else { + render::workspace_entries(snapshot, &self.collapsed_groups) + } + } + + pub(super) fn reveal_workspace(&mut self, workspace_id: &str) { + if self + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == workspace_id) + { + return; + } + let target = self.snapshot.as_deref().and_then(|snapshot| { + self.navigation_workspace_entries(snapshot) + .iter() + .position(|entry| snapshot.workspaces[entry.index].workspace_id == workspace_id) + }); + if let Some(target) = target { + self.workspace_scroll = target.min(self.hits.workspace_max_scroll); + } + } + + pub(super) fn layout(&self, cols: u16, rows: u16) -> ClientShellLayout { + self.config.layout( + cols, + rows, + self.sidebar_collapsed, + self.focused_tab_count(), + self.sidebar_width, + ) + } + + pub(crate) fn surface_size(&self, cols: u16, rows: u16) -> ClientSurfaceSize { + let surface = self.layout(cols, rows).pane_surface; + ClientSurfaceSize { + cols: surface.width.max(1), + rows: surface.height.max(1), + } + } + + pub(crate) fn set_snapshot(&mut self, snapshot: Box) { + if self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) { + return; + } + self.graphics.set_scope(&snapshot.boot_id); + let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { + current.commands.len() != snapshot.commands.len() + || current + .commands + .iter() + .zip(&snapshot.commands) + .any(|(left, right)| { + left.binding_labels != right.binding_labels || left.action != right.action + }) + }); + let endpoint_profile_changed = self.snapshot.as_ref().is_none_or(|current| { + current.server_keybindings_toml != snapshot.server_keybindings_toml + }); + let snapshot_keybindings_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands), + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed + || self + .snapshot + .as_ref() + .is_none_or(|current| current.commands != snapshot.commands) + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + let active_keymap_changed = match self.config.keybinding_source { + ClientShellKeybindingSource::Local => command_bindings_changed, + ClientShellKeybindingSource::Endpoint => { + endpoint_profile_changed || command_bindings_changed + } + ClientShellKeybindingSource::RemoteLocal => false, + }; + self.config_diagnostic = super::config::merged_config_diagnostic( + self.local_config_diagnostic.as_deref(), + snapshot.config_diagnostic.as_deref(), + ); + let boot_changed = self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); + if boot_changed + || self + .pane_surface + .as_ref() + .is_none_or(|surface| surface.projection_revision != snapshot.revision) + { + self.hits = ShellHitMap::default(); + } + if boot_changed { + self.pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.pending_notifications.clear(); + self.visible_notification = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } else if let Some(previous) = self + .snapshot + .as_deref() + .and_then(|current| current.focused_pane_id.as_ref()) + .filter(|previous| Some(previous.as_str()) != snapshot.focused_pane_id.as_deref()) + { + self.previous_pane_id = Some(previous.clone()); + } + if snapshot_keybindings_changed { + if let Err(err) = self.config.apply_snapshot_keybindings( + snapshot.server_keybindings_toml.as_deref(), + &snapshot.commands, + ) { + self.endpoint_error = Some(err); + } else if active_keymap_changed + && matches!( + self.mode, + ClientShellMode::Prefix | ClientShellMode::Navigate | ClientShellMode::Resize + ) + { + self.mode = ClientShellMode::Terminal; + } + } + let tab_layout_changed = self.snapshot.as_deref().is_none_or(|current| { + current.tabs.len() != snapshot.tabs.len() + || current + .tabs + .iter() + .zip(&snapshot.tabs) + .any(|(left, right)| { + left.tab_id != right.tab_id + || left.workspace_id != right.workspace_id + || left.label != right.label + || left.zoomed != right.zoomed + }) + || render::tab_bar_status_width(current) != render::tab_bar_status_width(&snapshot) + }); + if tab_layout_changed + || self + .snapshot + .as_deref() + .and_then(|current| current.focused_tab_id.as_deref()) + != snapshot.focused_tab_id.as_deref() + { + self.reveal_focused_tab = true; + } + if self.selection.as_ref().is_some_and(|selection| { + snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) + || !snapshot + .panes + .iter() + .any(|pane| pane.pane_id == selection.pane_id) + }) { + self.selection = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.last_pane_click = None; + } + if let Some(copy_pane_id) = self + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.pane_id.clone()) + { + let pane_exists = snapshot + .panes + .iter() + .any(|pane| pane.pane_id == copy_pane_id); + let pane_focused = snapshot.focused_pane_id.as_deref() == Some(copy_pane_id.as_str()); + if !pane_exists { + self.copy_mode = None; + self.reset_copy_pipeline(); + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } else if pane_focused { + if self.mode == ClientShellMode::Terminal { + self.mode = ClientShellMode::Copy; + } + if self.selection.is_none() { + self.sync_copy_selection(); + } + } else { + if self + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == copy_pane_id) + { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + } + } + if self.mode == ClientShellMode::Navigate + && self.navigate_workspace_id.as_ref().is_none_or(|selected| { + !snapshot + .workspaces + .iter() + .any(|workspace| &workspace.workspace_id == selected) + }) + { + self.navigate_workspace_id = snapshot.focused_workspace_id.clone(); + self.reveal_mobile_workspace = self.mobile_layout_active(); + } + let pane_exists = + |pane_id: &String| snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id); + self.pane_scroll_in_flight + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_queued + .retain(|pane_id, _| pane_exists(pane_id)); + self.pane_scroll_targets + .retain(|pane_id, _| pane_exists(pane_id)); + + if !self.config.startup_onboarding { + match snapshot.product_announcement.as_ref() { + Some(announcement) => { + let key = (announcement.version.clone(), announcement.id.clone()); + let already_open = matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(current)) + if current.version == announcement.version && current.id == announcement.id + ); + let may_open = self.overlay.is_none() + || matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ); + if self.dismissed_product_announcement.as_ref() != Some(&key) + && may_open + && !already_open + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + None if matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) => + { + self.overlay = None; + self.chrome_drag = None; + self.dismissed_product_announcement = None; + } + None => { + self.dismissed_product_announcement = None; + } + } + } + if let Some(ClientShellOverlay::ReleaseNotes(current)) = self.overlay.as_ref() { + match snapshot.release_notes.as_ref() { + Some(notes) + if current.version != notes.version + || current.body != notes.body + || current.preview != notes.preview => + { + self.overlay = + Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + None => { + self.overlay = None; + self.chrome_drag = None; + } + Some(_) => {} + } + } + self.snapshot = Some(snapshot); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id + || surface.projection_revision < snapshot.revision + || self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) + { + return; + } + if self + .snapshot + .as_ref() + .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) + { + self.hits = ShellHitMap::default(); + } + let previous_popup = self.popup_terminal_id.clone(); + let next_popup = surface + .popup + .as_deref() + .map(|popup| popup.terminal_id.clone()); + if previous_popup != next_popup { + if next_popup.is_some() && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) + { + self.cancel_settings_overlay(); + } + if let Some(terminal_id) = previous_popup.as_ref() { + self.input_leases + .remove_target(&ClientInputTarget::Popup(terminal_id.clone())); + } + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + if !matches!( + self.overlay.as_ref(), + Some(ClientShellOverlay::Onboarding | ClientShellOverlay::ProductAnnouncement(_)) + ) { + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + } + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + self.reset_copy_pipeline(); + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + if self.pane_mouse_gesture.as_ref().is_some_and(|gesture| { + gesture.hit.popup && previous_popup.as_deref() == Some(gesture.hit.pane_id.as_str()) + }) { + self.pane_mouse_gesture = None; + } + self.hits.popup = None; + self.endpoint_error = None; + } + if next_popup.is_some() { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + let selection_content_changed = self.selection.as_ref().is_some_and(|selection| { + let previous_revision = self.pane_surface.as_ref().and_then(|previous| { + previous + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision) + }); + let next_revision = surface + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id) + .map(|pane| pane.content_revision); + previous_revision.is_some() + && next_revision.is_some() + && previous_revision != next_revision + }); + if selection_content_changed { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + for pane in &surface.panes { + let Some(target) = self.pane_scroll_targets.get(&pane.pane_id).copied() else { + continue; + }; + let Some(scroll) = pane.scroll else { + continue; + }; + let target = + target.min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&pane.pane_id); + } + } + let mut invalidated_copy_pane = None; + if let Some(copy_mode) = self.copy_mode.as_mut() { + if let Some(pane) = surface + .panes + .iter() + .find(|pane| pane.pane_id == copy_mode.pane_id) + { + let geometry = (pane.inner_rect.width, pane.inner_rect.height); + if copy_mode.content_revision != pane.content_revision + || copy_mode.geometry != geometry + { + copy_mode.content_revision = pane.content_revision; + copy_mode.geometry = geometry; + copy_mode.selection = None; + copy_mode.search_matches.clear(); + copy_mode.search_total = 0; + copy_mode.search_current = None; + copy_mode.search_current_global = None; + copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); + copy_mode.copy_after_search = false; + invalidated_copy_pane = Some(copy_mode.pane_id.clone()); + } + if let Some(scroll) = pane.scroll { + let actual_offset = + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX); + if !self.pane_scroll_targets.contains_key(&pane.pane_id) { + copy_mode.offset_from_bottom = actual_offset; + } + copy_mode.max_offset_from_bottom = + usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX); + } + } + } + if invalidated_copy_pane.as_ref().is_some_and(|pane_id| { + self.selection + .as_ref() + .is_some_and(|selection| &selection.pane_id == pane_id) + }) { + self.selection = None; + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + } + self.popup_terminal_id = next_popup; + self.graphics + .set_scene(std::mem::take(&mut surface.graphics)); + self.pane_surface = Some(surface); + self.resume_mobile_switcher_if_ready(); + self.reconcile_input_source(); + } + + pub(crate) fn tick_popup_pending(&mut self, now: std::time::Instant) { + if self + .popup_pending_deadline + .is_some_and(|deadline| now >= deadline) + { + self.popup_pending = false; + self.popup_pending_deadline = None; + } + } + + pub(crate) fn show_copy_feedback(&mut self, now: std::time::Instant) -> bool { + if !self.config.clipboard_toast_enabled { + return false; + } + self.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied to clipboard".to_owned(), + }); + self.copy_feedback_deadline = Some(now + std::time::Duration::from_secs(2)); + true + } + + pub(crate) fn tick_copy_feedback(&mut self, now: std::time::Instant) -> bool { + let mut repaint = false; + if self + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.copy_feedback = None; + self.copy_feedback_deadline = None; + repaint = true; + } + if self + .selection_highlight_clear_deadline + .is_some_and(|deadline| now >= deadline) + { + self.selection = None; + self.selection_highlight_clear_deadline = None; + repaint = true; + } + repaint + } + + 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 + .map(|deadline| deadline.saturating_duration_since(now).min(default)) + .unwrap_or(default) + } + + pub(crate) fn invalidate_pane_surface(&mut self) { + self.pane_surface = None; + self.hits = ShellHitMap::default(); + self.host_mouse_pixels = None; + } + + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs new file mode 100644 index 00000000..10486f50 --- /dev/null +++ b/src/client/shell/surface_patch.rs @@ -0,0 +1,266 @@ +use super::*; + +pub(crate) struct ClientComposedSurfacePatch { + pub(crate) rows: Vec, + pub(crate) cursor: Option, +} + +pub(crate) enum ClientPaneSurfacePatchOutcome { + Rejected, + Applied(Option), +} + +fn row_fits_frame(row: &crate::protocol::PaneSurfacePatchRow, frame: &FrameData) -> bool { + row.x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= frame.width + && row.y < frame.height +} + +fn apply_row(row: &crate::protocol::PaneSurfacePatchRow, frame: &mut FrameData) -> bool { + if !row_fits_frame(row, frame) { + return false; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + if end > frame.cells.len() { + return false; + } + frame.cells[start..end].clone_from_slice(&row.cells); + true +} + +fn apply_patch_to_surface( + surface: &mut crate::protocol::PaneSurfaceFrame, + patch: &crate::protocol::PaneSurfacePatch, +) -> bool { + for row in &patch.rows { + if !apply_row(row, &mut surface.frame) { + return false; + } + } + for updated in &patch.panes { + let Some(existing) = surface + .panes + .iter_mut() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return false; + }; + *existing = updated.clone(); + } + surface.frame.cursor = patch.cursor.clone(); + surface.surface_revision = patch.surface_revision; + true +} + +fn fast_path_blocker( + state: &ClientShellState, + patch: &crate::protocol::PaneSurfacePatch, +) -> Option<&'static str> { + if state.mode != ClientShellMode::Terminal { + Some("client_surface_patch.fallback.mode") + } else if state.overlay.is_some() { + Some("client_surface_patch.fallback.overlay") + } else if state.endpoint_error.is_some() { + Some("client_surface_patch.fallback.endpoint_error") + } else if state.config_diagnostic.is_some() { + Some("client_surface_patch.fallback.config_diagnostic") + } else if state.visible_notification.is_some() { + Some("client_surface_patch.fallback.notification") + } else if state.copy_feedback.is_some() { + Some("client_surface_patch.fallback.copy_feedback") + } else if state.selection.is_some() { + Some("client_surface_patch.fallback.selection") + } else if state.copy_mode.is_some() { + Some("client_surface_patch.fallback.copy_mode") + } else if state.selection_highlight_clear_deadline.is_some() { + Some("client_surface_patch.fallback.selection_deadline") + } else if patch.panes.iter().any(|pane| { + !state + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane.pane_id) + }) { + Some("client_surface_patch.fallback.pane_hits") + } else { + None + } +} + +fn pane_geometry_matches( + left: &crate::protocol::PaneSurfacePane, + right: &crate::protocol::PaneSurfacePane, +) -> bool { + left.pane_id == right.pane_id + && left.rect == right.rect + && left.inner_rect == right.inner_rect + && left.focused == right.focused + && left.pixel_width == right.pixel_width + && left.pixel_height == right.pixel_height +} + +impl ClientShellState { + pub(crate) fn apply_pane_surface_patch( + &mut self, + patch: crate::protocol::PaneSurfacePatch, + ) -> ClientPaneSurfacePatchOutcome { + let Some(current) = self.pane_surface.as_ref() else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if patch.boot_id != current.boot_id + || patch.projection_revision != current.projection_revision + || patch.base_surface_revision != current.surface_revision + || patch.surface_revision != current.surface_revision.saturating_add(1) + || current.popup.is_some() + || !current.graphics.placements.is_empty() + || !current.graphics.retained_assets.is_empty() + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + + for updated in &patch.panes { + let Some(existing) = current + .panes + .iter() + .find(|pane| pane.pane_id == updated.pane_id) + else { + return ClientPaneSurfacePatchOutcome::Rejected; + }; + if !pane_geometry_matches(existing, updated) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + for row in &patch.rows { + if !row_fits_frame(row, ¤t.frame) + || row.cells.is_empty() + || !patch.panes.iter().any(|pane| { + let terminal_row = row.x >= pane.inner_rect.x + && row.y >= pane.inner_rect.y + && row.y < pane.inner_rect.y.saturating_add(pane.inner_rect.height) + && row + .x + .saturating_add(row.cells.len().min(u16::MAX as usize) as u16) + <= pane.inner_rect.x.saturating_add(pane.inner_rect.width); + let scrollbar_rect = pane.scrollbar_rect.or_else(|| { + current + .panes + .iter() + .find(|existing| existing.pane_id == pane.pane_id) + .and_then(|existing| existing.scrollbar_rect) + }); + let scrollbar_row = scrollbar_rect.is_some_and(|rect| { + row.x == rect.x + && row.y >= rect.y + && row.y < rect.y.saturating_add(rect.height) + && row.cells.len() == usize::from(rect.width) + }); + terminal_row || scrollbar_row + }) + { + return ClientPaneSurfacePatchOutcome::Rejected; + } + } + + let fast_path_blocker = fast_path_blocker(self, &patch); + if let Some(reason) = fast_path_blocker { + crate::render_prof::event(reason); + } + let fast_path_area = fast_path_blocker.is_none().then(|| { + let (cols, rows) = self.last_composed_size.unwrap_or_default(); + self.layout(cols, rows).pane_surface + }); + let composed_patch = fast_path_area.map(|area| ClientComposedSurfacePatch { + rows: patch + .rows + .iter() + .map(|row| crate::protocol::PaneSurfacePatchRow { + x: area.x.saturating_add(row.x), + y: area.y.saturating_add(row.y), + cells: row.cells.clone(), + }) + .collect(), + cursor: patch + .cursor + .clone() + .map(|cursor| crate::protocol::CursorState { + x: area.x.saturating_add(cursor.x), + y: area.y.saturating_add(cursor.y), + visible: cursor.visible, + shape: cursor.shape, + }), + }); + if let Some(area) = fast_path_area { + let applied = self + .pane_surface + .as_mut() + .is_some_and(|surface| apply_patch_to_surface(surface, &patch)); + if !applied { + return ClientPaneSurfacePatchOutcome::Rejected; + } + for updated in &patch.panes { + let Some(hit) = self + .hits + .panes + .iter_mut() + .find(|hit| hit.pane_id == updated.pane_id) + else { + continue; + }; + hit.scrollbar_rect = updated.scrollbar_rect.map(|rect| { + Rect::new( + area.x.saturating_add(rect.x), + area.y.saturating_add(rect.y), + rect.width, + rect.height, + ) + }); + hit.scroll = updated.scroll.map(|metrics| crate::pane::ScrollMetrics { + offset_from_bottom: usize::try_from(metrics.offset_from_bottom) + .unwrap_or(usize::MAX), + max_offset_from_bottom: usize::try_from(metrics.max_offset_from_bottom) + .unwrap_or(usize::MAX), + viewport_rows: usize::try_from(metrics.viewport_rows).unwrap_or(usize::MAX), + }); + hit.mouse_reporting = updated.mouse_reporting; + hit.sgr_pixel_mouse = updated.sgr_pixel_mouse; + hit.pixel_width = updated.pixel_width; + hit.pixel_height = updated.pixel_height; + if let (Some(target), Some(scroll)) = ( + self.pane_scroll_targets.get(&updated.pane_id).copied(), + updated.scroll, + ) { + let target = target + .min(usize::try_from(scroll.max_offset_from_bottom).unwrap_or(usize::MAX)); + if usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX) == target { + self.pane_scroll_targets.remove(&updated.pane_id); + } + } + } + self.reconcile_input_source(); + } else { + let mut next = current.clone(); + if !apply_patch_to_surface(&mut next, &patch) { + return ClientPaneSurfacePatchOutcome::Rejected; + } + self.set_pane_surface(next); + } + ClientPaneSurfacePatchOutcome::Applied(composed_patch) + } +} + +#[cfg(test)] +pub(crate) fn apply_composed_surface_patch( + frame: &FrameData, + patch: ClientComposedSurfacePatch, +) -> Option { + let mut next = frame.clone(); + for row in &patch.rows { + if !apply_row(row, &mut next) { + return None; + } + } + next.cursor = patch.cursor; + Some(next) +} diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs new file mode 100644 index 00000000..cf179d80 --- /dev/null +++ b/src/client/shell/tabs.rs @@ -0,0 +1,386 @@ +use super::*; + +const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; +const MIN_TAB_STRIP_WIDTH: u16 = + MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); + +pub(crate) fn render_tab_bar( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + tab_scroll: &mut usize, + reveal_focused_tab: &mut bool, + tab_drag_insert_index: Option, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + buffer.set_style(area, Style::default().bg(palette.panel_bg)); + let tabs = snapshot + .tabs + .iter() + .filter(|tab| Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()) + .collect::>(); + let desired_widths = tabs + .iter() + .map(|tab| { + let label = tab_label(tab); + display_width(&label).saturating_add(4).max(MIN_TAB_WIDTH) + }) + .collect::>(); + let content = tab_bar_content_area(snapshot, area); + let mouse_chrome = config.mouse_capture; + let new_tab_width = if mouse_chrome { NEW_TAB_WIDTH } else { 0 }; + let desired_total = desired_widths + .iter() + .copied() + .fold(0_u16, u16::saturating_add) + .saturating_add(tabs.len().saturating_sub(1).min(u16::MAX as usize) as u16) + .saturating_add(new_tab_width); + let overflow = + desired_total > content.width && (!mouse_chrome || content.width >= MIN_TAB_STRIP_WIDTH); + let available = if overflow && mouse_chrome { + content + .width + .saturating_sub(NEW_TAB_WIDTH) + .saturating_sub(TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2)) + } else { + content.width.saturating_sub(new_tab_width) + }; + let max_scroll = max_tab_scroll(&desired_widths, available); + if !overflow { + *tab_scroll = 0; + } else if *reveal_focused_tab { + if let Some(focused) = tabs.iter().position(|tab| tab.focused) { + *tab_scroll = centered_tab_scroll(focused, &desired_widths, available).min(max_scroll); + } + } else { + *tab_scroll = (*tab_scroll).min(max_scroll); + } + *reveal_focused_tab = false; + + let mut x = content.x; + let tab_right = if overflow && mouse_chrome { + hits.tab_scroll_left = Rect::new( + content.x, + content.y, + TAB_SCROLL_BUTTON_WIDTH.min(content.width), + 1, + ); + put_text( + buffer, + hits.tab_scroll_left.x, + content.y, + hits.tab_scroll_left.width, + " < ", + Style::default() + .fg(if *tab_scroll > 0 { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + x = hits.tab_scroll_left.right(); + content + .right() + .saturating_sub(NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH) + } else { + content.right().saturating_sub(new_tab_width) + }; + + let mut first_visible = None; + let mut last_visible = None; + for (index, tab) in tabs.iter().enumerate().skip(*tab_scroll) { + let name = tab_label(tab); + let desired = desired_widths[index]; + let remaining = tab_right.saturating_sub(x); + let width = desired.min(remaining); + if width == 0 { + break; + } + let rect = Rect::new(x, area.y, width, 1); + let style = if tab.focused { + let base = Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent); + if tab.custom_label { + base.add_modifier(Modifier::BOLD) + } else { + base + } + } 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) + }; + let padding = width.saturating_sub(display_width(&name)); + let left = padding / 2; + let text = format!( + "{empty:left$}{name}{empty:right_padding$}", + empty = "", + left = left as usize, + right_padding = padding.saturating_sub(left) as usize, + ); + put_text(buffer, rect.x, rect.y, rect.width, &text, style); + hits.tabs.push((rect, tab.tab_id.clone())); + first_visible.get_or_insert(index); + last_visible = Some(index); + x = x.saturating_add(width + 1); + if width < desired { + break; + } + } + + if overflow && mouse_chrome { + hits.tab_scroll_right = Rect::new(tab_right, area.y, TAB_SCROLL_BUTTON_WIDTH, 1); + let can_scroll_right = last_visible.is_some_and(|index| index + 1 < tabs.len()); + put_text( + buffer, + hits.tab_scroll_right.x, + area.y, + hits.tab_scroll_right.width, + " > ", + Style::default() + .fg(if can_scroll_right { + palette.overlay1 + } else { + palette.overlay0 + }) + .bg(palette.surface0), + ); + hits.new_tab = Rect::new( + hits.tab_scroll_right.right(), + area.y, + content + .right() + .saturating_sub(hits.tab_scroll_right.right()) + .min(NEW_TAB_WIDTH), + 1, + ); + } else if mouse_chrome { + hits.new_tab = Rect::new( + x.min(content.right()), + area.y, + content.right().saturating_sub(x).min(NEW_TAB_WIDTH), + 1, + ); + } + if mouse_chrome { + put_text( + buffer, + hits.new_tab.x, + area.y, + hits.new_tab.width, + " + ", + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + } + + if first_visible.is_some_and(|index| index > 0) { + let ellipsis_x = if hits.tab_scroll_left.width > 0 { + hits.tab_scroll_left.right() + } else { + content.x + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + if last_visible.is_some_and(|index| index + 1 < tabs.len()) { + let ellipsis_x = if hits.tab_scroll_right.width > 0 { + hits.tab_scroll_right.x.saturating_sub(1) + } else { + content.right().saturating_sub(1) + }; + put_text( + buffer, + ellipsis_x, + area.y, + u16::from(ellipsis_x >= content.x && ellipsis_x < content.right()), + "…", + Style::default().fg(palette.overlay0), + ); + } + + if let Some(insert_index) = tab_drag_insert_index { + if let Some(indicator_x) = tab_drop_indicator_x(hits, &tabs, insert_index) { + put_text( + buffer, + indicator_x.min(content.right().saturating_sub(1)), + area.y, + 1, + "│", + Style::default().fg(palette.accent), + ); + } + } + render_tab_bar_status(buffer, area, snapshot, palette); +} + +pub(crate) fn tab_bar_status_width(snapshot: &ClientShellSnapshot) -> u16 { + let content = snapshot.tab_bar_right.iter().fold(0u16, |width, segment| { + width.saturating_add(display_width(&segment.text)) + }); + let separators = snapshot.tab_bar_right.len().saturating_sub(1); + content.saturating_add( + display_width(&snapshot.tab_bar_right_separator) + .saturating_mul(separators.min(u16::MAX as usize) as u16), + ) +} + +fn tab_bar_status_area(snapshot: &ClientShellSnapshot, area: Rect) -> Option { + let width = tab_bar_status_width(snapshot); + if width == 0 { + return None; + } + let reserved = width.saturating_add(1); + (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) + .then(|| Rect::new(area.right().saturating_sub(width), area.y, width, 1)) +} + +fn tab_bar_content_area(snapshot: &ClientShellSnapshot, area: Rect) -> Rect { + let reserved = tab_bar_status_area(snapshot, area) + .map(|status| status.width.saturating_add(1)) + .unwrap_or(0); + Rect { + width: area.width.saturating_sub(reserved), + ..area + } +} + +fn render_tab_bar_status( + buffer: &mut Buffer, + area: Rect, + snapshot: &ClientShellSnapshot, + palette: &Palette, +) { + let Some(status) = tab_bar_status_area(snapshot, area) else { + return; + }; + let separator_width = display_width(&snapshot.tab_bar_right_separator); + let mut x = status.x; + for (index, segment) in snapshot.tab_bar_right.iter().enumerate() { + if index > 0 && separator_width > 0 { + put_text( + buffer, + x, + area.y, + separator_width, + &snapshot.tab_bar_right_separator, + Style::default().fg(palette.overlay0).bg(palette.panel_bg), + ); + x = x.saturating_add(separator_width); + } + let width = display_width(&segment.text); + let style = if segment.accent { + Style::default() + .fg(panel_contrast_fg(palette)) + .bg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.overlay1).bg(palette.panel_bg) + }; + put_text(buffer, x, area.y, width, &segment.text, style); + x = x.saturating_add(width); + } +} + +fn tab_drop_indicator_x( + hits: &ShellHitMap, + tabs: &[&ClientShellTab], + insert_index: usize, +) -> Option { + let visible = hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + if insert_index == 0 { + return Some(if first_index == 0 { + first_rect.x + } else { + hits.tab_scroll_left.right() + }); + } + if let Some((_, rect)) = visible.iter().find(|(index, _)| *index == insert_index) { + return Some(rect.x.saturating_sub(1)); + } + if insert_index >= tabs.len() { + return Some(if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + hits.tab_scroll_right.x.saturating_sub(1) + }); + } + None +} + +fn centered_tab_scroll(focused: usize, widths: &[u16], available: u16) -> usize { + let mut best = focused; + let mut best_distance = u16::MAX; + for start in 0..=focused { + let before = widths + .iter() + .copied() + .enumerate() + .skip(start) + .take(focused.saturating_sub(start)) + .fold(0u16, |width, (_, tab)| width.saturating_add(tab + 1)); + if before >= available { + continue; + } + let focused_width = widths[focused].min(available.saturating_sub(before)); + let center = before.saturating_mul(2).saturating_add(focused_width); + let distance = center.abs_diff(available); + if distance <= best_distance { + best_distance = distance; + best = start; + } + } + best +} + +fn max_tab_scroll(widths: &[u16], available: u16) -> usize { + (0..widths.len()) + .find(|start| last_visible_tab(*start, widths, available) == widths.len().checked_sub(1)) + .unwrap_or(0) +} + +fn last_visible_tab(start: usize, widths: &[u16], available: u16) -> Option { + let mut remaining = available; + let mut last = None; + for (index, width) in widths.iter().copied().enumerate().skip(start) { + if remaining == 0 { + break; + } + last = Some(index); + if width >= remaining { + break; + } + remaining = remaining.saturating_sub(width.saturating_add(1)); + } + last +} + +fn tab_label(tab: &ClientShellTab) -> String { + if tab.zoomed { + format!("{} Z", tab.label) + } else { + tab.label.clone() + } +} diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs new file mode 100644 index 00000000..0ee5aa3f --- /dev/null +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -0,0 +1,1248 @@ +use super::*; + +#[test] +fn mouse_hits_use_stable_workspace_tab_and_pane_ids() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace_down.actions.is_empty()); + let workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &workspace.actions[..] else { + panic!("workspace click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + )); + + let pane = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(pane.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &pane.actions[..] else { + panic!("pane click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); +} + +#[test] +fn collapsed_workspace_jitter_remains_a_click() { + 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.compose(106, 20).expect("collapsed sidebar"); + let workspace = state.hits.workspaces[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: workspace.x, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.chrome_drag.is_none()); + assert!(state.workspace_press.is_some()); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn grouped_worktrees_render_parent_branch_and_indented_child() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + snapshot.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_ws2".into(), + new_workspace_cwd: "/repo/feature".into(), + number: 2, + label: "repo-feature".into(), + custom_label: false, + branch: Some("worktree/feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }), + focused: false, + agent_status: AgentStatus::Idle, + }); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("composed 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("main")); + assert!(text.contains("└─")); + assert!(text.contains("feature")); + + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.focused_workspace_id = Some("ws_2".into()); + replacement.workspaces[0].focused = false; + replacement.workspaces[1].focused = true; + replacement.workspaces[1].agent_status = AgentStatus::Blocked; + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.collapsed_groups.insert("repo".into()); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let collapsed = state.compose(106, 20).expect("collapsed worktree group"); + let parent = state.hits.workspaces[0].rect; + let status_cell = usize::from(parent.y) * usize::from(collapsed.width) + + usize::from(parent.x.saturating_add(1)); + assert_eq!( + collapsed.cells[status_cell].fg, + crate::protocol::color_to_u32(state.config.palette.red) + ); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextWorkspace), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("three workspaces"); + let first = state.hits.workspaces[0].rect; + let third = state.hits.workspaces[2].rect; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 2, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Workspace { + ref source_workspace_id, + target: Some((None, _)), + }) if source_workspace_id == "ws_1" + )); + let frame = state.compose(106, 24).expect("workspace drop indicator"); + assert!(frame + .cells + .chunks(frame.width as usize) + .any(|row| row.iter().take(20).any(|cell| cell.symbol == "─"))); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMove(params) + if params.workspace_id == "ws_1" && params.insert_index == 3 + ) + )); + + state.compose(106, 24).expect("workspaces after drag"); + let second = state.hits.workspaces[1].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + ) + )); +} + +#[test] +fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { + let mut projected = snapshot(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut child = projected.workspaces[0].clone(); + child.workspace_id = "ws_child".into(); + child.number = 2; + child.label = "feature".into(); + child.focused = false; + child.worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut other = projected.workspaces[0].clone(); + other.workspace_id = "ws_other".into(); + other.number = 3; + other.label = "other".into(); + other.focused = false; + other.worktree = None; + projected.workspaces.extend([child, other]); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("worktree workspaces"); + assert!(state.hits.workspaces[1].indented); + let parent = state.hits.workspaces[0].rect; + let child = state.hits.workspaces[1].rect; + let other = state.hits.workspaces[2].rect; + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: parent.x + 2, + row: parent.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &moved.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMoveBlock(params) + if params.workspace_ids == ["ws_1", "ws_child"] + && params.before_workspace_id.is_none() + ) + )); + + state.compose(106, 24).expect("worktree child"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: child.x + 2, + row: child.y, + modifiers: KeyModifiers::empty(), + })]); + let dragging_child = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragging_child.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() { + let mut initial = snapshot(); + let mut second = initial.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + initial.panes.push(second); + initial.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(initial.clone())); + + let mut cycle = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CyclePaneNext), + &mut cycle, + ); + let [ClientShellAction::Endpoint { request, .. }] = &cycle.actions[..] else { + panic!("pane cycle should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + let mut replacement = initial; + replacement.revision = 2; + replacement.focused_pane_id = Some("pane_2".into()); + replacement.panes[0].focused = false; + replacement.panes[1].focused = true; + state.set_snapshot(Box::new(replacement)); + let mut last = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::LastPane), + &mut last, + ); + let [ClientShellAction::Endpoint { request, .. }] = &last.actions[..] else { + panic!("last pane should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + + let mut agent = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(1)), + &mut agent, + ); + let [ClientShellAction::Endpoint { request, .. }] = &agent.actions[..] else { + panic!("agent focus should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); +} + +#[test] +fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi one".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("first title".into()), + terminal_title_stripped: Some("first".into()), + agent_status: AgentStatus::Done, + state_change_seq: 10, + state_labels: Vec::new(), + tokens: vec![("summary".into(), "review complete".into())], + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi two".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("second title".into()), + terminal_title_stripped: Some("second".into()), + agent_status: AgentStatus::Blocked, + state_change_seq: 20, + state_labels: vec![("blocked".into(), "needs input".into())], + tokens: vec![("summary".into(), "waiting for Can".into())], + focused: false, + }, + ]; + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + config.ui.sidebar.agents.rows_by_agent.insert( + "pi".into(), + vec![ + vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::StateText, + ], + vec![ + crate::config::AgentSidebarToken::Agent, + crate::config::AgentSidebarToken::Custom("summary".into()), + ], + ], + ); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 30).expect("agent sidebar 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("× needs input"), "frame: {text}"); + assert!(text.contains("pi two"), "frame: {text}"); + assert!(text.contains("waiting for"), "frame: {text}"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + + let first = state.hits.agents[0].0; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("agent row should focus through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + state.compose(106, 10).expect("short agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + let body = state.hits.agent_body; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: body.x, + row: body.y, + modifiers: KeyModifiers::empty(), + })]); + state + .compose(106, 10) + .expect("scrolled agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_1") + ); + + state.sidebar_collapsed = true; + let compact = state.compose(106, 30).expect("compact agent sidebar frame"); + let blocked = state + .hits + .agents + .iter() + .find(|(_, pane_id)| pane_id == "pane_2") + .expect("blocked compact agent") + .0; + let row_start = blocked.y as usize * compact.width as usize + blocked.x as usize; + assert_ne!(compact.cells[row_start].fg, compact.cells[row_start + 2].fg); + assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg); +} + +#[test] +fn active_agent_view_controls_sidebar_order_and_focus_indices() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane.clone()); + let mut third_pane = second_pane; + third_pane.pane_id = "pane_3".into(); + projected.panes.push(third_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ClientShellAgent { + pane_id: "pane_3".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("third".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 3, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + projected.agent_view_label = Some("review".into()); + projected.agent_order = vec!["pane_2".into(), "pane_3".into()]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("filtered agent sidebar"); + assert_eq!( + state + .hits + .agents + .iter() + .map(|(_, pane_id)| pane_id.as_str()) + .collect::>(), + vec!["pane_2", "pane_3"] + ); + assert_eq!(state.hits.agent_sort_toggle, Rect::default()); + + let mut focus = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(0)), + &mut focus, + ); + assert!(matches!( + &focus.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextAgent), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); +} + +#[test] +fn agent_sort_toggle_is_client_local_and_persists_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-agent-sort-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".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 config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("agent sidebar frame"); + let toggle = state.hits.agent_sort_toggle; + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!( + reloaded.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(reloaded.agent_panel_sort_manual); + std::fs::remove_file(path).expect("remove agent sort preferences"); +} + +#[test] +fn workspace_actions_preserve_selected_target_and_client_confirmation() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + + let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::SHIFT, + ))]); + assert!(rename.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { workspace_id }, + .. + })) if workspace_id == "ws_2" + )); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + assert!(state.handle_input_bytes(b"renamed").actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("workspace rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceRename(params) + if params.workspace_id == "ws_2" && params.label == "renamed" + )); + + state.navigate_workspace_id = Some("ws_2".into()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace), + &mut close, + ); + assert!(close.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + workspace_id, + .. + })) if workspace_id == "ws_2" + )); + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("workspace confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_2" && params.close_group + )); +} + +#[test] +fn desktop_workspace_navigation_reveals_overflowing_selection() { + let mut projected = snapshot(); + for index in 2..=8 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_1".into()); + state.compose(106, 12).expect("overflowing sidebar"); + + for _ in 0..6 { + state.handle_input_bytes(b"\x1b[B"); + state.compose(106, 12).expect("revealed workspace"); + let selected = state.navigate_workspace_id.as_deref().expect("selection"); + assert!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == selected), + "{selected} should remain visible" + ); + } +} + +#[test] +fn named_workspace_overlay_targets_projected_source_workspace() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + &mut open, + ); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + input: value, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + .. + }, + .. + })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") + )); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("named workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceCreate(params) + if params.source_workspace_id.as_deref() == Some("ws_1") + && params.cwd.as_deref() == Some("/repo") + && params.label.is_none() + )); +} + +#[test] +fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let enter_navigate = state.handle_input_bytes(b"w"); + assert!(enter_navigate.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let invalid = state.handle_input_bytes(b"9"); + assert!(invalid.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let move_selection = state.handle_input_bytes(b"\x1b[B"); + assert!(move_selection.actions.is_empty()); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2")); + let frame = state.compose(106, 20).expect("navigate 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("second")); + assert!(text.contains("NAVIGATE")); + + let focus = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &focus.actions[..] else { + panic!("selected workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn worktree_create_previews_the_endpoint_owned_checkout_path() { + 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"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeList(params) + if params.workspace_id.as_deref() == Some("ws_1") + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))) + .0 + ); + let frame = state.compose(106, 30).expect("new worktree modal"); + 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("new worktree")); + assert!(text.contains("create and open")); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state + .handle_input_bytes(b"feature/client-shell") + .actions + .is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) + if create.checkout_path + == "/tmp/herdr-worktrees/repo/feature-client-shell" + )); + let submit = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { + panic!("worktree create should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.branch.as_deref() == Some("feature/client-shell") + && params.path.is_none() + && params.focus + )); +} + +#[test] +fn worktree_open_filters_and_clicks_a_stable_public_entry() { + 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::OpenWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("open worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))); + let frame = state.compose(106, 30).expect("open worktree modal"); + 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("feature")); + let row = state.hits.worktree_rows[0].0; + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: row.x + 2, + row: row.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &open.actions[..] else { + panic!("worktree row should open through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeOpen(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.path.as_deref() == Some("/repo-feature") + && params.focus + )); +} + +#[test] +fn worktree_remove_escalates_dirty_failure_to_force_confirmation() { + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo-key".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + 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::RemoveWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("remove worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(worktree_list_result(Some("ws_1"))), + ); + let remove = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &remove.actions[..] else { + panic!("worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && !params.force + )); + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("dirty_worktree_requires_force".into()), + message: "dirty worktree".into(), + }), + ); + let frame = state.compose(106, 30).expect("force remove modal"); + 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("delete anyway")); + assert!(text.contains("permanently deleted")); + let force = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &force.actions[..] else { + panic!("forced worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && params.force + )); +} + +#[test] +fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_2".into(), + tab_id: "tab_2".into(), + name: None, + display_agent: Some("codex".into()), + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let now = std::time::Instant::now(); + let (effects, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: Some("other · 2".into()), + sound: Some(SemanticNotificationSound::Request), + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Sound { + sound: crate::sound::Sound::Request, + .. + }] + )); + let frame = state.compose(100, 28).expect("notification frame"); + let rendered = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(rendered.contains("codex needs attention")); + let hit = state.hits.notification_toast; + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + }) + }; + state.mode = ClientShellMode::Navigate; + let ignored = state.handle_raw_events(vec![click()]); + assert!(ignored.actions.is_empty()); + assert!(state.visible_notification.is_some()); + + state.mode = ClientShellMode::Terminal; + let outcome = state.handle_raw_events(vec![click()]); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + let mut keybind = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNotificationTarget), + &mut keybind, + ); + assert!(keybind.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "first".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(state.visible_notification.is_some()); + state.config.toast_delay_seconds = 1; + let (_, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "replacement".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(state.visible_notification.is_none()); + assert_eq!(state.pending_notifications.len(), 1); +} diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs new file mode 100644 index 00000000..eeb272c2 --- /dev/null +++ b/src/client/shell/tests/chrome_context.rs @@ -0,0 +1,339 @@ +use super::*; + +#[test] +fn tab_overflow_controls_scroll_the_client_owned_tab_bar() { + let mut snapshot = snapshot(); + snapshot.tabs.extend((2..=8).map(|number| ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + 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(snapshot)); + state.set_pane_surface(surface()); + state.compose(80, 20).expect("overflow tab bar"); + + assert!(state.hits.tab_scroll_right.width > 0); + let scroll_right = state.hits.tab_scroll_right; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: scroll_right.x + 1, + row: scroll_right.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.repaint); + assert_eq!(state.tab_scroll, 1); + + let mut update = state.snapshot.as_deref().expect("snapshot").clone(); + update.focused_tab_id = Some("tab_8".into()); + for tab in &mut update.tabs { + tab.focused = tab.tab_id == "tab_8"; + } + state.set_snapshot(Box::new(update)); + state.compose(80, 20).expect("focused overflow tab"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); + + state.compose(300, 20).expect("tabs without overflow"); + assert_eq!(state.tab_scroll, 0); + assert_eq!(state.hits.tabs.len(), 8); + state.compose(80, 20).expect("focused tab after narrowing"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8")); +} + +#[test] +fn client_owned_sidebar_dividers_resize_live() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("expanded sidebar"); + let workspace_body = state.hits.workspace_body; + let needless_scroll = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: workspace_body.x, + row: workspace_body.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.hits.workspace_max_scroll, 0); + assert_eq!(state.workspace_scroll, 0); + assert!(!needless_scroll.repaint); + let width_divider = state.hits.sidebar_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: width_divider.x, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let resize = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.sidebar_width, 32); + assert!(state.sidebar_width_manual); + assert!(resize.repaint); + assert!(resize.resize); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 31, + row: width_divider.y + 2, + modifiers: KeyModifiers::empty(), + })]); + + state.set_pane_surface(surface()); + state.compose(106, 30).expect("resized sidebar"); + let section_divider = state.hits.sidebar_section_divider; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: section_divider.x + 2, + row: section_divider.y, + modifiers: KeyModifiers::empty(), + })]); + let split = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: section_divider.x + 2, + row: 20, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.sidebar_section_split > 0.6); + assert!(split.repaint); + assert!(!split.resize); +} + +#[test] +fn context_menus_capture_stable_targets_and_route_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace = state.hits.workspaces[0].rect; + let open_workspace_menu = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: workspace.x + 2, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_workspace_menu.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + target: ClientContextMenuTarget::Workspace { ref workspace_id, .. }, + .. + })) if workspace_id == "ws_1" + )); + let workspace_items = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(), + _ => panic!("workspace context menu"), + }; + assert!(workspace_items + .iter() + .any(|item| item.action == ClientContextMenuAction::NewWorktree)); + state.compose(106, 20).expect("workspace context menu"); + let rename = state.hits.context_menu_rows[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rename.x + 1, + row: rename.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { ref workspace_id }, + .. + })) if workspace_id == "ws_1" + )); + + state.overlay = None; + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.x + 1, + row: pane.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("pane context menu"); + let split_index = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu + .items() + .iter() + .position(|item| item.action == ClientContextMenuAction::SplitRight) + .expect("split right item"), + _ => panic!("pane context menu"), + }; + let split = state.hits.context_menu_rows[split_index].0; + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.x + 1, + row: split.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("pane split context action should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSplit(params) + if params.target_pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::SplitDirection::Right + )); +} + +#[test] +fn global_menu_opens_from_sidebar_and_routes_client_actions() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + let launcher = state.hits.global_launcher; + assert_ne!(launcher, Rect::default()); + + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: launcher.x, + row: launcher.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open.repaint); + let menu = state.compose(106, 30).expect("global menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("settings")); + assert!(text.contains("keybinds")); + assert!(text.contains("reload config")); + assert!(text.contains("detach")); + + let keybinds = state.hits.global_menu_rows[1].0; + let help = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: keybinds.x, + row: keybinds.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(help.actions.is_empty()); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let detach = state.handle_input_bytes(b"\r"); + assert!(detach.detach); + assert!(state.overlay.is_none()); +} + +#[test] +fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + &mut open, + ); + assert!(open.actions.is_empty()); + let frame = state.compose(106, 20).expect("new tab overlay"); + 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("new tab")); + assert!(text.contains("save")); + let restored = frame.to_ratatui_buffer().expect("overlay frame"); + assert!(!restored + .cell((26, 7)) + .expect("overlay title cell") + .modifier + .contains(Modifier::DIM)); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"logs").actions.is_empty()); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("new tab save should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.label.as_deref() == Some("logs") + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane), + &mut close, + ); + let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else { + panic!("pane close should use endpoint API"); + }; + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "confirmation required".into(), + }), + ) + .0 + ); + let frame = state.compose(106, 20).expect("confirmation overlay"); + 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("Close workspace?")); + assert!(text.contains("1 pane")); + + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group + )); +} diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs new file mode 100644 index 00000000..64cc78e6 --- /dev/null +++ b/src/client/shell/tests/copy.rs @@ -0,0 +1,1190 @@ +use super::*; + +#[test] +fn pasted_help_and_copy_queries_strip_control_characters() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: true, + scroll: 0, + })); + + assert!(state.insert_overlay_text("work\nspace")); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. })) + if query == "workspace" + )); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + + assert!(state.insert_copy_search_text("needle\r\n")); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); +} + +#[test] +fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| !selection.is_visible())); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + let selected = state.compose(106, 20).expect("selected frame"); + let selected_cell = + &selected.cells[usize::from(pane.inner_rect.y) * 106 + usize::from(pane.inner_rect.x)]; + assert_ne!( + selected_cell.bg, + crate::protocol::color_to_u32(ratatui::style::Color::Reset) + ); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.selection.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("selection release should request endpoint extraction"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.pane_id == "pane_1" + && params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIV".into(), + }), + ); + assert!(repaint); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIV" + )); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); +} + +#[test] +fn clipboard_feedback_is_client_local_and_respects_config() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let now = std::time::Instant::now(); + assert!(state.show_copy_feedback(now)); + assert_eq!( + state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + assert_eq!( + state.copy_feedback_deadline, + Some(now + std::time::Duration::from_secs(2)) + ); + + state.config.clipboard_toast_enabled = false; + state.copy_feedback = None; + state.copy_feedback_deadline = None; + assert!(!state.show_copy_feedback(now)); + assert!(state.copy_feedback.is_none()); + assert!(state.copy_feedback_deadline.is_none()); +} + +#[test] +fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + for event in [ + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }, + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(event)]); + } + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))]); + assert!(state.selection.is_none()); + assert!(matches!( + ©.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + )); + assert!(copy.requests.is_empty()); + let request_id = match ©.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let (_, fallback) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: String::new(), + }), + ); + assert!(matches!( + &fallback[..], + [ClientShellAction::Request(ClientMessage::ClientShellPaneInput { + pane_id, + events, + })] if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Press, + .. + }, ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn selection_edge_drag_requests_scroll_and_timer_continues_it() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y.saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 3 + ) + )); + let drag_request_id = match &drag.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let now = std::time::Instant::now(); + state.selection_autoscroll_deadline = Some(now); + let tick = state.tick_selection_autoscroll(now); + assert!(tick.actions.is_empty()); + let (_, next_scroll) = + state.handle_endpoint_result("boot-1", &drag_request_id, Ok(pane_scroll_result(3, 20, 3))); + assert!(matches!( + &next_scroll[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 4 + ) + )); +} + +#[test] +fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + assert_eq!(state.mode, ClientShellMode::Copy); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(21) + ); + assert!(enter.actions.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + + let page = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageUp, + KeyModifiers::empty(), + ))]); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(20) + ); + assert!(matches!( + &page.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 1 + ) + )); + let page_request_id = match &page.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + + let top = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::empty(), + ))]); + assert!(top.actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(0) + ); + let (_, top_actions) = + state.handle_endpoint_result("boot-1", &page_request_id, Ok(pane_scroll_result(1, 20, 2))); + let [ClientShellAction::Endpoint { request, .. }] = &top_actions[..] else { + panic!("latest queued scroll should follow the completed request"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" && params.offset_from_bottom == 20 + )); + let top_request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &top_request_id, Ok(pane_scroll_result(20, 20, 2))); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('l'), + KeyModifiers::empty(), + ))]); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('y'), + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.copy_mode.is_none()); + assert!(state.selection.is_none()); + assert_eq!(copy.actions.len(), 2); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn keyboard_copy_mode_content_motion_is_endpoint_backed_and_stale_safe() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let motion = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &motion.actions[..] else { + panic!("word motion should use endpoint semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == origin + && params.motion == crate::api::schema::PaneCopyMotion::NextWordStart + )); + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: crate::api::schema::PaneTextPoint { + row: origin.row, + col: 3, + }, + content_revision: 0, + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.col), + Some(3) + ); +} + +#[test] +fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('?'), + KeyModifiers::SHIFT, + ))]); + assert!(state.copy_mode.as_ref().is_some_and(|mode| { + mode.search_prompt.as_ref().is_some_and(|prompt| { + prompt.direction == crate::api::schema::PaneCopySearchDirection::Backward + }) + })); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_prompt.is_none())); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "junk", + ))]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL, + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "nee", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("dleX".into())]); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Backspace, + KeyModifiers::empty(), + ))]); + assert_eq!( + state + .copy_mode + .as_ref() + .and_then(|mode| mode.search_prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); + + let search = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &search.actions[..] else { + panic!("search should use endpoint terminal semantics"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.pane_id == "pane_1" + && params.query == "needle" + && params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.cursor == origin + && params.previous.is_none() + )); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + assert!(repaint); + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(5) + ); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 15 + ) + ))); + let initial_scroll_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("initial search scroll"); + state.handle_endpoint_result( + "boot-1", + &initial_scroll_id, + Ok(pane_scroll_result(15, 20, 2)), + ); + let mut scrolled_surface = state.pane_surface.clone().expect("pane surface"); + scrolled_surface.panes[0] + .scroll + .as_mut() + .expect("scroll metrics") + .offset_from_bottom = 15; + state.set_pane_surface(scrolled_surface); + let frame = state.compose(106, 20).expect("search frame"); + let hit = state.hits.panes[0].clone(); + let viewport_top = 5u16; + let restored = frame.to_ratatui_buffer().expect("search frame buffer"); + let highlighted = restored + .cell((hit.inner_rect.x + 2, hit.inner_rect.y + (5 - viewport_top))) + .expect("highlighted search cell"); + assert_eq!(highlighted.bg, state.config.palette.accent); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('v'), + KeyModifiers::empty(), + ))]); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat should use endpoint search"); + }; + let repeat_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Forward + && params.previous == Some(matches[0]) + )); + let (_, repeat_actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches.clone(), Some(1))), + ); + if let Some(scroll_id) = repeat_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(6, 20, 2))); + } + assert_eq!( + state.copy_mode.as_ref().map(|mode| mode.cursor.row), + Some(15) + ); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); + + let reverse = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &reverse.actions[..] else { + panic!("reverse search should use endpoint search"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneCopySearch(params) + if params.direction == crate::api::schema::PaneCopySearchDirection::Backward + && params.previous == Some(matches[1]) + )); + let (_, reverse_actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + if let Some(scroll_id) = reverse_actions.iter().find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneScroll(_)) => + { + Some(request.id.clone()) + } + _ => None, + }) { + state.handle_endpoint_result("boot-1", &scroll_id, Ok(pane_scroll_result(15, 20, 2))); + } + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|mode| mode.search_query.is_empty() && mode.selection.is_none())); + let exit = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(exit.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.offset_from_bottom == 0 + ) + ))); +} + +#[test] +fn navigator_owns_search_mouse_selection_and_stable_target_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNavigator), + &mut open, + ); + let navigator = state.compose(106, 30).expect("navigator overlay"); + let navigator_text = navigator + .cells + .chunks(navigator.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(navigator_text.contains("client-shell")); + assert!(navigator_text.contains("pane 1")); + + let search = state.hits.navigator_search; + let focus_search = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: search.x, + row: search.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(focus_search.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + search_focused: true, + .. + })) + )); + assert!(state.handle_input_bytes(b"client").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered navigator"); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + state.handle_input_bytes(b"\x1b"); + state.handle_input_bytes(b"a"); + state.compose(106, 30).expect("navigator rows"); + let pane_index = { + let snapshot = state.snapshot.as_deref().expect("snapshot"); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(snapshot, navigator) + .iter() + .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .expect("pane row") + }; + let pane_rect = state + .hits + .navigator_rows + .iter() + .find(|(_, index)| *index == pane_index) + .map(|(rect, _)| *rect) + .expect("visible pane row"); + let select = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(select.repaint); + let accept = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane_rect.x + 6, + row: pane_rect.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &accept.actions[..] else { + panic!("navigator pane click should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + + state.handle_input_bytes(b"v"); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let mut unfocused = snapshot(); + unfocused.focused_pane_id = Some("pane_2".into()); + unfocused.panes[0].focused = false; + unfocused.panes.push(ClientShellPane { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.selection.is_some())); + + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + state.set_snapshot(Box::new(unfocused.clone())); + assert_eq!(state.mode, ClientShellMode::Prefix); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + + let mut other_selection = + crate::selection::Selection::absolute_range("pane_2".to_owned(), (0, 0), (0, 1)); + assert!(other_selection.finish()); + state.selection = Some(other_selection); + state.set_snapshot(Box::new(unfocused)); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_2")); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ignored", + ))]); + state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + + state.mode = ClientShellMode::Navigate; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); + state.mode = ClientShellMode::Resize; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state + .selection + .as_ref() + .is_some_and(|selection| selection.pane_id == "pane_1")); +} + +#[test] +fn retained_selection_copy_suppresses_key_repeats() { + let mut config = Config::default(); + config.ui.copy_on_select = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + + let key = crate::input::TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key.clone())]); + assert!(press.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ))); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.actions.is_empty()); + assert!(repeat.requests.is_empty()); +} + +#[test] +fn rapid_copy_motions_are_chained_from_the_previous_result() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + + let first = state.handle_input_bytes(b"w"); + let second = state.handle_input_bytes(b"w"); + assert_eq!(first.actions.len(), 1); + assert!(second.actions.is_empty()); + let first_id = match &first.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let intermediate = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, follow_up) = state.handle_endpoint_result( + "boot-1", + &first_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: intermediate, + content_revision: 0, + }), + ); + assert!(matches!( + &follow_up[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneCopyMotion(params) + if params.cursor == intermediate + ) + )); +} + +#[test] +fn queued_copy_keys_preserve_prefix_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + state.handle_input_bytes(b"l"); + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + prefix_key, + prefix_modifiers, + ))]); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: origin, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Prefix); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.cursor.col), + Some(origin.col.saturating_add(1)) + ); +} + +#[test] +fn reentering_copy_mode_on_the_same_pane_is_a_no_op() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 10, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut first = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut first)); + state + .copy_mode + .as_mut() + .expect("copy mode") + .offset_from_bottom = 10; + let mut reenter = ClientShellInput::default(); + assert!(state.enter_copy_mode(&mut reenter)); + assert!(reenter.actions.is_empty()); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.entry_offset_from_bottom), + Some(0) + ); +} + +#[test] +fn copy_waits_for_endpoint_motion_before_copying_selection() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + state.handle_input_bytes(b"v"); + let origin = state.copy_mode.as_ref().expect("copy mode").cursor; + let motion = state.handle_input_bytes(b"w"); + let queued_copy = state.handle_input_bytes(b"y"); + assert!(queued_copy.actions.is_empty()); + let motion_id = match &motion.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let target = crate::api::schema::PaneTextPoint { + row: origin.row, + col: 2, + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &motion_id, + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: "pane_1".into(), + cursor: target, + content_revision: 0, + }), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == origin && params.cursor == target + ) + ))); +} + +#[test] +fn new_content_revision_invalidates_copy_search_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let copy_mode = state.copy_mode.as_mut().expect("copy mode"); + copy_mode.search_query = "needle".into(); + copy_mode + .search_matches + .push(crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 1 }, + }); + copy_mode.search_total = 1; + copy_mode.search_current = Some(0); + copy_mode.search_current_global = Some(0); + + pane_surface.panes[0].content_revision = 2; + state.set_pane_surface(pane_surface); + let copy_mode = state.copy_mode.as_ref().expect("copy mode retained"); + assert!(copy_mode.search_matches.is_empty()); + assert_eq!(copy_mode.search_total, 0); + assert_eq!(copy_mode.search_current, None); +} + +#[test] +fn word_selection_result_survives_focus_snapshot_lag() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let hit = state.hits.panes[0].clone(); + let mut request = ClientShellInput::default(); + state.request_word_selection(&hit, 0, 1, &mut request); + let request_id = match &request.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + let mut lagging = snapshot(); + lagging.focused_pane_id = None; + lagging.panes[0].focused = false; + state.set_snapshot(Box::new(lagging)); + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "hello world".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible)); +} diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs new file mode 100644 index 00000000..bc72b01a --- /dev/null +++ b/src/client/shell/tests/input.rs @@ -0,0 +1,609 @@ +use super::*; + +#[test] +fn host_appearance_prefers_explicit_reports_over_background_inference() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.theme_runtime.auto_switch = true; + + let light = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Light, + ); + let dark = crate::app::client_palette_for_appearance( + &state.config.theme_runtime, + crate::terminal_theme::HostAppearance::Dark, + ); + + let inferred = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(inferred.repaint); + assert!(matches!( + inferred.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Background, + .. + } + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!state.host_appearance_explicit); + assert_eq!(state.config.palette, light); + + let explicit = state.handle_raw_events(vec![RawInputEvent::HostColorSchemeChanged( + crate::terminal_theme::HostAppearance::Dark, + )]); + assert!(explicit.repaint); + assert!(explicit.query_host_theme); + assert!(matches!( + explicit.requests.as_slice(), + [ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ) + }] + )); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(state.host_appearance_explicit); + assert_eq!(state.config.palette, dark); + + let ignored = state.handle_raw_events(vec![RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 255, + g: 255, + b: 255, + }, + }]); + assert!(!ignored.repaint); + assert_eq!(ignored.requests.len(), 1); + assert_eq!( + state.host_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert_eq!(state.config.palette, dark); +} + +#[test] +fn modal_paste_shortcut_modifiers_are_platform_specific() { + let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers); + + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key( + KeyCode::Char('V'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ), + false + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + false + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::CONTROL), + true + )); + assert!(input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::SUPER), + true + )); + assert!(!input::is_modal_paste_shortcut_for_platform( + &key(KeyCode::Char('v'), KeyModifiers::ALT), + true + )); +} + +#[test] +fn modal_paste_inserts_clipboard_text_through_overlay_text_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: "replace me".into(), + replace_on_type: true, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + let mut outcome = ClientShellInput::default(); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + assert!( + state.handle_modal_paste_shortcut_with(&key, &mut outcome, || { + Some("feature/pasted".into()) + }) + ); + assert!(outcome.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. })) + if input == "feature/pasted" + )); +} + +#[test] +fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let key = crate::protocol::SurfaceGraphicsAssetKey { + source: crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: crate::protocol::SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 17, + }; + pane_surface.graphics = crate::protocol::SurfaceGraphicsScene { + assets: vec![crate::protocol::SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 4], + }], + placements: vec![crate::protocol::SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 1, + x: 0, + y: 0, + 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, + }], + retained_assets: Vec::new(), + }; + state.set_pane_surface(pane_surface); + + let visible = state.compose(106, 20).expect("visible graphics frame"); + let visible = String::from_utf8_lossy(&visible.graphics); + assert!(visible.contains("a=t,t=d")); + assert!(visible.contains("\u{1b}[2;27H")); + + 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")); + + state.overlay = None; + let restored = state.compose(106, 20).expect("restored graphics frame"); + let restored = String::from_utf8_lossy(&restored.graphics); + assert!(restored.contains("a=p")); + assert!(!restored.contains("a=t,t=d")); +} + +#[test] +fn delayed_link_fallback_does_not_replay_against_changed_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn invalid_experimental_reload_keeps_input_source_preference() { + let mut shell = ClientShellConfig::from_config(&Config::default()); + shell.switch_ascii_input_source_in_prefix = true; + let config = Config::default(); + shell.apply_live_config(&config, &[], &["experimental".to_owned()]); + assert!(shell.switch_ascii_input_source_in_prefix); + shell.apply_live_config(&config, &[], &[]); + assert!(!shell.switch_ascii_input_source_in_prefix); +} + +#[test] +fn physical_release_uses_the_leased_press_code_with_current_modifiers() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let press = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0, + }); + state.handle_raw_events(vec![RawInputEvent::Key(press)]); + let release = crate::input::TerminalKey::new(KeyCode::Char('z'), KeyModifiers::SHIFT) + .with_kind(crossterm::event::KeyEventKind::Release) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 0x5a, + virtual_scan_code: 0x2d, + unicode: 0, + control_key_state: 0x0010, + }); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(release)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + kind: crate::protocol::ClientKeyKind::Release, + physical_key_id: Some(0x2d), + .. + }] if *modifiers == KeyModifiers::SHIFT.bits() + ) + )); +} + +#[test] +fn highlighted_search_match_copies_after_in_flight_repeat() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + let matches = vec![ + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 5, col: 2 }, + end: crate::api::schema::PaneTextPoint { row: 5, col: 7 }, + }, + crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 15, col: 1 }, + end: crate::api::schema::PaneTextPoint { row: 15, col: 6 }, + }, + ]; + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('/'), + KeyModifiers::empty(), + ))]); + state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "needle", + ))]); + let initial = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Enter, KeyModifiers::empty()), + )]); + let [ClientShellAction::Endpoint { request, .. }] = &initial.actions[..] else { + panic!("initial search request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(matches.clone(), Some(0))), + ); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('n'), + KeyModifiers::empty(), + ))]); + let [ClientShellAction::Endpoint { request, .. }] = &repeat.actions[..] else { + panic!("repeat search request"); + }; + let repeat_id = request.id.clone(); + + let early_copy = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()), + )]); + assert!(early_copy.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Copy); + + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &repeat_id, + Ok(copy_search_result(matches, Some(1))), + ); + assert_eq!(state.mode, ClientShellMode::Terminal); + let selection_request_id = actions + .iter() + .find_map(|action| match action { + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::PaneSelectionRead(_) + ) => + { + Some(request.id.clone()) + } + _ => None, + }) + .expect("deferred selection read"); + let (_, clipboard) = state.handle_endpoint_result( + "boot-1", + &selection_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "needle".into(), + }), + ); + assert!(matches!( + &clipboard[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"needle" + )); +} + +#[test] +fn pixel_host_reports_use_cells_without_target_pixel_mode_and_release_outside() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + + let down = state.handle_pixel_mouse(format!("\x1b[<0;{x};{y}M").as_bytes(), geometry); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + + state.hits.panes.clear(); + let release = state.handle_pixel_mouse(b"\x1b[<0;1;1m", geometry); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { .. }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn shell_targets_unconsumed_input_and_keeps_prefix_local() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + + let text = state.handle_input_bytes(b"hello"); + assert_eq!(text.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { pane_id, events } = &text.requests[0] else { + panic!("expected targeted pane input"); + }; + assert_eq!(pane_id, "pane_1"); + assert_eq!(events.len(), 5); + assert!(matches!( + &events[0], + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('h'), + generated_text: Some(text), + .. + } if text == "h" + )); + + let interrupt = state.handle_input_bytes(b"\x1b[99;5u"); + assert_eq!(interrupt.requests.len(), 1); + let ClientMessage::ClientShellPaneInput { events, .. } = &interrupt.requests[0] else { + panic!("expected semantic interrupt"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers, + kind: crate::protocol::ClientKeyKind::Press, + .. + }] if *modifiers == KeyModifiers::CONTROL.bits() + )); + + let alt = state.handle_input_bytes(b"\x1b[120;3u"); + let ClientMessage::ClientShellPaneInput { events, .. } = &alt.requests[0] else { + panic!("expected semantic alt key"); + }; + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + assert!(!state.handle_input_bytes(&[0x02]).detach); + let detach = state.handle_input_bytes(b"q"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn pane_key_release_keeps_the_press_target() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + let press = state.handle_input_bytes(b"\x1b[99;5u"); + let release = state.handle_input_bytes(b"\x1b[99;5:3u"); + let ClientMessage::ClientShellPaneInput { + pane_id: press_target, + .. + } = &press.requests[0] + else { + panic!("expected targeted press"); + }; + let ClientMessage::ClientShellPaneInput { + pane_id: release_target, + events, + } = &release.requests[0] + else { + panic!("expected targeted release"); + }; + assert_eq!(release_target, press_target); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + )); +} + +#[test] +fn help_overlay_uses_live_keymap_and_owns_filter_state() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("keybinds")); + assert!(text.contains("prefix mode")); + + assert!(state.handle_input_bytes(b"/").actions.is_empty()); + assert!(state.handle_input_bytes(b"workspace").actions.is_empty()); + let filtered = state.compose(106, 30).expect("filtered help"); + let text = filtered + .cells + .chunks(filtered.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("workspace navigation")); + assert!(!text.contains("prefix mode")); + assert!(filtered + .cursor + .as_ref() + .is_some_and(|cursor| cursor.visible)); + + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + assert!(state.handle_input_bytes(b"\x1b").repaint); + assert!(state.overlay.is_none()); +} + +#[test] +fn rename_pane_empty_value_is_preserved_as_a_clear_request() { + let mut snapshot = snapshot(); + snapshot.panes[0].label = Some("build".into()); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RenamePane), + &mut open, + ); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("pane rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneRename(params) + if params.pane_id == "pane_1" && params.label.as_deref() == Some("") + )); +} + +#[test] +fn styled_client_composition_preserves_pane_hyperlinks() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let linked = Buffer::with_lines(["LIVE", "PANE"]); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &linked, + None, + &[((0, 0), "L".into(), "https://example.test".into())], + ); + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + let frame = state.compose(106, 20).expect("composed frame"); + let hit = &state.hits.panes[0]; + let index = + usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x); + let link = frame.cells[index].hyperlink.expect("linked cell") as usize; + assert_eq!(frame.hyperlinks[link], "https://example.test"); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs new file mode 100644 index 00000000..5d2d8243 --- /dev/null +++ b/src/client/shell/tests/keybindings_settings.rs @@ -0,0 +1,633 @@ +use super::*; + +#[test] +fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = false; + config.ui.prompt_new_tab_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let new_workspace = state.hits.new_workspace; + let create_workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x + 1, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &create_workspace.actions[..] else { + panic!("new workspace click should use the endpoint API"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceCreate(_) + )); + + let new_tab = state.hits.new_tab; + let open_new_tab = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_tab.x + 1, + row: new_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(open_new_tab.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); +} + +#[test] +fn manual_client_chrome_preferences_round_trip_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-client-shell-prefs-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_section_split = 0.7; + state.sidebar_section_split_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.collapsed_groups.insert("repo-two".into()); + state.collapsed_groups.insert("repo-one".into()); + state.persist_chrome_preferences(&mut ClientShellInput::default()); + + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!(reloaded.sidebar_width, 31); + assert!(reloaded.sidebar_width_manual); + assert_eq!(reloaded.sidebar_section_split, 0.7); + assert!(reloaded.sidebar_section_split_manual); + assert!(reloaded.sidebar_collapsed); + assert!(reloaded.sidebar_collapsed_manual); + assert_eq!( + reloaded.collapsed_groups, + HashSet::from(["repo-one".to_string(), "repo-two".to_string()]) + ); + std::fs::remove_file(path).expect("remove client chrome preferences"); +} + +#[test] +fn tab_bar_renders_endpoint_status_ellipses_and_clamps_to_useful_scroll() { + let mut projected = snapshot(); + projected.tab_bar_right = vec![ + crate::protocol::ClientShellTabStatusSegment { + text: "ZOOM".into(), + accent: true, + }, + crate::protocol::ClientShellTabStatusSegment { + text: "host".into(), + accent: false, + }, + ]; + projected.tab_bar_right_separator = " · ".into(); + for number in 2..=8 { + projected.tabs.push(ClientShellTab { + tab_id: format!("tab_{number}"), + workspace_id: "ws_1".into(), + number, + label: number.to_string(), + custom_label: false, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mobile_width_threshold = 0; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("status and overflow tabs"); + let top = frame.cells[..frame.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(top.contains("ZOOM · host")); + assert!(top.contains('…')); + + state.tab_scroll = usize::MAX; + state.reveal_focused_tab = false; + state.compose(106, 20).expect("clamped tab scroll"); + assert!(state.tab_scroll < 7); + let manual_scroll = state.tab_scroll; + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.tab_bar_right[1].text = "tick".into(); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + assert!(!state.reveal_focused_tab); + state.compose(106, 20).expect("same-width status update"); + assert_eq!(state.tab_scroll, manual_scroll); + + state.compose(45, 20).expect("narrow tabs win over status"); + let narrow = state.compose(45, 20).expect("narrow tab frame"); + let top = narrow.cells[..narrow.width as usize] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(!top.contains("ZOOM · host")); +} + +#[test] +fn configured_prefix_is_client_owned_and_renders_its_bar() { + let config = toml::from_str::( + r#" +[keys] +prefix = "ctrl+a" +detach = "prefix+x" +"#, + ) + .expect("configured keybinds"); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let old_default = state.handle_input_bytes(&[0x02]); + assert_eq!( + old_default.requests.len(), + 1, + "ctrl-b should reach the pane" + ); + + let prefix = state.handle_input_bytes(&[0x01]); + assert!(prefix.requests.is_empty()); + assert!(prefix.repaint); + let frame = state.compose(106, 20).expect("prefix 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("PREFIX"), "frame: {text:?}"); + assert!(text.contains("ctrl+a"), "frame: {text:?}"); + + let detach = state.handle_input_bytes(b"x"); + assert!(detach.detach); + assert!(detach.requests.is_empty()); +} + +#[test] +fn prefix_endpoint_action_uses_public_api_with_stable_ids() { + let mut config = Config::default(); + config.ui.prompt_new_tab_name = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let create = state.handle_input_bytes(b"c"); + let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + panic!("expected one endpoint action: {:?}", create.actions); + }; + assert_eq!(boot_id, "boot-1"); + match &request.method { + crate::api::schema::Method::TabCreate(params) => { + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert!(params.focus); + } + other => panic!("expected tab.create, got {other:?}"), + } + assert!(state.pending_requests.contains_key(&request.id)); +} + +#[test] +fn remote_keybinding_sources_keep_local_commands_off_endpoints_and_apply_server_profiles() { + let local: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+c" + +[[keys.command]] +key = "prefix+c" +command = "local-only" +"#, + ) + .unwrap(); + let remote_local = ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); + assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); + assert_eq!( + remote_local.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+c") + ); + + let mut local_state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Local), + ); + let mut local_projection = snapshot(); + local_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_loaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.set_snapshot(Box::new(local_projection)); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+y" + ); + assert_eq!( + local_state + .config + .keybinds + .keybinds + .new_tab + .label() + .as_deref(), + Some("prefix+c") + ); + let mut command_outcome = ClientShellInput::default(); + local_state.record_binding( + crate::input::KeybindMatch::Command( + local_state.config.keybinds.keybinds.custom_commands[0].clone(), + ), + &mut command_outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &command_outcome.actions[..] else { + panic!("expected surviving endpoint command binding"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command invocation"); + }; + assert_eq!(params.command_id, "cmd_loaded_endpoint"); + + let mut id_only_projection = snapshot(); + id_only_projection.revision = 2; + id_only_projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_reloaded_endpoint".into(), + binding_label: "prefix+c / prefix+y".into(), + binding_labels: vec!["prefix+c".into(), "prefix+y".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("loaded endpoint command".into()), + }); + local_state.mode = ClientShellMode::Prefix; + local_state.set_snapshot(Box::new(id_only_projection)); + assert_eq!(local_state.mode, ClientShellMode::Prefix); + assert_eq!( + local_state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_reloaded_endpoint" + ); + + let endpoint: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+x" +new_tab = "prefix+n" +"#, + ) + .unwrap(); + let mut state = ClientShellState::new( + ClientShellConfig::from_config(&local) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint), + ); + let mut projection = snapshot(); + projection.server_keybindings_toml = endpoint.local_keybindings_profile_toml().ok(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_remote".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::Shell, + description: Some("remote command".into()), + }); + state.set_snapshot(Box::new(projection)); + + assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!( + state.config.keybinds.keybinds.new_tab.label().as_deref(), + Some("prefix+n") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].label, + "prefix+z" + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0] + .description + .as_deref(), + Some("remote command") + ); + assert_eq!( + state.config.keybinds.keybinds.custom_commands[0].command, + "cmd_remote" + ); +} + +#[test] +fn custom_binding_invokes_only_the_endpoint_manifest_id() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Shell, + description: None, + }); + state.set_snapshot(Box::new(projection)); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!(params.command_id, "cmd_0123456789abcdef0123456789abcdef"); + assert_eq!(params.workspace_id.as_deref(), Some("ws_1")); + assert_eq!(params.tab_id.as_deref(), Some("tab_1")); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert_eq!(params.selection, None); + assert!(!serde_json::to_string(request) + .unwrap() + .contains("secret-command")); +} + +#[test] +fn plugin_command_carries_client_owned_selection_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("p"), + label: "prefix+p".into(), + command: "plugin.action".into(), + action: crate::config::CustomCommandAction::PluginAction, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_plugin".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: None, + }); + state.set_snapshot(Box::new(projection)); + let mut pane_surface = surface(); + pane_surface.panes[0].content_revision = 42; + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (2, 3), (4, 5)); + assert!(selection.finish()); + state.selection = Some(selection); + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected endpoint command invocation"); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!( + params.selection, + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: "pane_1".into(), + anchor: crate::api::schema::PaneTextPoint { row: 2, col: 3 }, + cursor: crate::api::schema::PaneTextPoint { row: 4, col: 5 }, + content_revision: Some(42), + }) + ); +} + +#[test] +fn generic_endpoint_failures_and_control_errors_are_visible() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: "missing".into(), + }), + &mut outcome, + ); + let request_id = match &outcome.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected generic endpoint request, got {other:?}"), + }; + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("not_found".into()), + message: "workspace no longer exists".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert_eq!( + state.endpoint_error.as_deref(), + Some("workspace no longer exists") + ); + + assert!(state.receive_endpoint_error("Paste rejected: too large".into())); + assert_eq!( + state.endpoint_error.as_deref(), + Some("Paste rejected: too large") + ); + assert!(!state.receive_endpoint_error("Paste rejected: too large".into())); +} + +#[test] +fn custom_binding_missing_from_endpoint_manifest_is_not_forwarded() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command".into(), + action: crate::config::CustomCommandAction::Shell, + description: None, + width: None, + height: None, + }; + + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert!(state + .endpoint_error + .as_deref() + .is_some_and(|error| error.contains("not available"))); +} + +#[test] +fn help_overlay_restores_released_search_scroll_and_custom_binding_behavior() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "plugin-action".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: crate::protocol::ClientShellCommandAction::PluginAction, + description: Some("run plugin action".into()), + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut open, + ); + let initial = state.compose(106, 30).expect("help overlay"); + let text = initial + .cells + .chunks(initial.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("global")); + assert!(state.hits.help_max_scroll > 0); + assert_ne!(state.hits.help_scrollbar, Rect::default()); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"plugin"); + let custom = state.compose(106, 30).expect("custom help search"); + let text = custom + .cells + .chunks(custom.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("custom")); + assert!(text.contains("run plugin action")); + state.handle_input_bytes(b"\x1b"); + + state.handle_input_bytes(b"/"); + state.handle_input_bytes(b"does-not-exist"); + let empty = state.compose(106, 30).expect("empty help search"); + let text = empty + .cells + .chunks(empty.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("no matching keybinds")); + + state.handle_input_bytes(b"\x1b"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: false, + ref query, + scroll: 0, + })) if query.is_empty() + )); + state.compose(106, 30).expect("restored help"); + state.handle_input_bytes(b"\x1b[F"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { scroll, .. })) + if scroll == state.hits.help_max_scroll + )); + state.handle_input_bytes(b"\x1b[H"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + scroll: 0, + .. + })) + )); + state.handle_input_bytes(b"?"); + assert!(state.overlay.is_none()); +} + +#[test] +fn resize_mode_reuses_endpoint_resize_and_stays_active_until_done() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + assert!(state.handle_input_bytes(b"r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Resize); + + let modified = state.handle_input_bytes(b"\x1b[1;2D"); + assert!(matches!( + &modified.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.direction == crate::api::schema::PaneDirection::Left + ) + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + let resize = state.handle_input_bytes(b"h"); + let [ClientShellAction::Endpoint { request, .. }] = &resize.actions[..] else { + panic!("resize should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneResize(params) + if params.pane_id.as_deref() == Some("pane_1") + && params.direction == crate::api::schema::PaneDirection::Left + )); + assert_eq!(state.mode, ClientShellMode::Resize); + + assert!(state.handle_input_bytes(b"\r").actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs new file mode 100644 index 00000000..094b141f --- /dev/null +++ b/src/client/shell/tests/mobile.rs @@ -0,0 +1,559 @@ +use super::*; + +#[test] +fn navigate_update_status_uses_released_desktop_and_mobile_placement() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; + config.hide_tab_bar_when_single_tab = false; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + + let bottom = state.compose(106, 30).expect("bottom-tab update shell"); + let row_text = |frame: &FrameData, row: u16| { + let width = usize::from(frame.width); + let start = usize::from(row) * width; + frame.cells[start..start + width] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }; + assert!(row_text(&bottom, 29).contains("update ready")); + assert!(!row_text(&bottom, 28).contains("update ready")); + assert!(state.hits.tabs.is_empty()); + assert!(state.hits.new_tab.is_empty()); + assert!(state.hits.tab_scroll_left.is_empty()); + assert!(state.hits.tab_scroll_right.is_empty()); + + state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "bottom notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::BottomRight), + }, + deadline: std::time::Instant::now(), + }); + let top = state.compose(106, 30).expect("top-tab update shell"); + assert!(row_text(&top, 29).contains("update ready")); + + let mobile = state.compose(44, 30).expect("mobile update shell"); + let mobile_text = mobile + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(mobile_text.contains("update ready")); +} + +#[test] +fn mobile_layout_reserves_only_client_header() { + let config = ClientShellConfig::from_config(&Config::default()); + let state = ClientShellState::new(config); + let layout = state.layout(44, 20); + assert_eq!(layout.mobile_header, Rect::new(0, 0, 44, 2)); + assert_eq!(layout.pane_surface, Rect::new(0, 2, 44, 18)); + assert_eq!( + state.surface_size(44, 20), + ClientSurfaceSize { cols: 44, rows: 18 } + ); +} + +#[test] +fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.mouse_capture = false; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(44, 20).expect("mobile header"); + assert!(!state.hits.mobile_switch.is_empty()); + let switch = state.hits.mobile_switch; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: switch.x, + row: switch.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + state.compose(44, 20).expect("mobile switcher"); + assert!(!state.hits.mobile_close.is_empty()); + assert!(!state.hits.mobile_targets.is_empty()); +} + +#[test] +fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: Some("pi".into()), + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: vec![("blocked".into(), "waiting".into())], + tokens: Vec::new(), + focused: true, + }); + projected.workspaces[0].agent_status = AgentStatus::Blocked; + state.set_snapshot(Box::new(projected)); + let mut projected_surface = surface(); + for cell in &mut projected_surface.frame.cells { + cell.symbol = "X".to_owned(); + } + state.set_pane_surface(projected_surface); + + let header = state.compose(44, 20).expect("mobile header"); + let header_text = header + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(header_text.contains("client-shell")); + assert!(header_text.contains("tab 1")); + assert!(header_text.contains("blocked")); + assert!(header_text.contains("switch")); + assert_eq!(state.hits.mobile_switch, Rect::new(34, 0, 10, 2)); + + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let opened = state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + assert!(opened.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + let switcher = state.compose(44, 20).expect("mobile switcher"); + let switcher_text = switcher + .cells + .chunks(switcher.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + !switcher_text.contains('X'), + "switcher must clear the pane surface" + ); + for expected in [ + "switch", + "close", + "agents", + "spaces", + "+ new workspace", + "tabs", + "+ new tab", + "menu", + "settings", + "detach", + ] { + assert!(switcher_text.contains(expected), "missing {expected}"); + } + let workspace_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + }) + .expect("workspace hit"); + let focused = state.handle_raw_events(vec![click(workspace_hit)]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(params) + if params.workspace_id == "ws_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("agent switcher"); + let agent_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + }) + .expect("agent hit"); + let focused = state.handle_raw_events(vec![click(agent_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1" + ) + ))); + + state.compose(44, 20).expect("restored mobile header"); + state.handle_raw_events(vec![click(state.hits.mobile_switch)]); + state.compose(44, 20).expect("tab switcher"); + let tab_hit = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + }) + .expect("tab hit"); + let focused = state.handle_raw_events(vec![click(tab_hit)]); + assert!(focused.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(params) + if params.tab_id == "tab_1" + ) + ))); +} + +#[test] +fn mobile_background_workspace_uses_its_own_active_tab_status() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + projected.tabs.push(ClientShellTab { + tab_id: "tab_7".into(), + workspace_id: "ws_1".into(), + number: 7, + label: "logs".into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + projected.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_3".into(), + new_workspace_cwd: "/feature".into(), + number: 2, + label: "background".into(), + custom_label: true, + branch: Some("feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + for (number, tab_id, label) in [(1, "tab_2", "one"), (7, "tab_3", "two")] { + projected.tabs.push(ClientShellTab { + tab_id: tab_id.into(), + workspace_id: "ws_2".into(), + number, + label: label.into(), + custom_label: true, + zoomed: false, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + let frame = state.compose(44, 20).expect("mobile switcher"); + 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("feature · tab two · 2/2"), "{text}"); + assert!(text.contains("2 · logs"), "{text}"); + assert!(!text.contains("7 · logs"), "{text}"); +} + +#[test] +fn mobile_switcher_create_and_menu_rows_reuse_client_actions() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.prompt_new_workspace_name = true; + config.prompt_new_tab_name = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let click = |rect: Rect| { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile create switcher"); + let new_tab = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::NewTab).then_some(*rect)) + .expect("new tab hit"); + state.handle_raw_events(vec![click(new_tab)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewTab { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile workspace switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace hit"); + state.handle_raw_events(vec![click(new_workspace)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::NewWorkspace { .. }, + .. + })) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile menu switcher"); + let settings = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(0)).then_some(*rect)) + .expect("settings hit"); + state.handle_raw_events(vec![click(settings)]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() { + let mut source_config = Config::default(); + source_config.ui.prompt_new_workspace_name = true; + let config = ClientShellConfig::from_config(&source_config); + let mut projected = snapshot(); + projected.latest_release_notes_available = true; + projected.release_notes = None; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("mobile switcher"); + let inert_notes = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| matches!(target, ClientMobileTarget::Menu(3)).then_some(*rect)) + .expect("what's new row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: inert_notes.x, + row: inert_notes.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(state.overlay.is_none()); + assert!(!state.mobile_switcher_suspended); + + let mut empty = snapshot(); + empty.focused_workspace_id = None; + empty.focused_tab_id = None; + empty.focused_pane_id = None; + empty.workspaces.clear(); + empty.tabs.clear(); + empty.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&source_config)); + state.set_snapshot(Box::new(empty)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.compose(44, 20).expect("empty mobile switcher"); + let new_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::NewWorkspace).then_some(*rect) + }) + .expect("new workspace row"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: new_workspace.x, + row: new_workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!(state.overlay, Some(ClientShellOverlay::Rename(_)))); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Navigate); +} + +#[test] +fn mobile_previous_workspace_action_wraps_across_expanded_entries() { + let mut projected = snapshot(); + for index in 2..=3 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + 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()); + state.compose(44, 20).expect("mobile layout"); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousWorkspace), + &mut outcome, + ); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_3" + ) + ))); +} + +#[test] +fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut projected = snapshot(); + for index in 2..=8 { + projected.workspaces.push(ClientShellWorkspace { + workspace_id: format!("ws_{index}"), + active_tab_id: format!("tab_{index}"), + new_workspace_cwd: "/tmp".into(), + number: index, + label: format!("workspace-{index}"), + custom_label: true, + branch: None, + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: false, + agent_status: AgentStatus::Idle, + }); + } + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(44, 10).expect("mobile header"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: state.hits.mobile_switch.x, + row: state.hits.mobile_switch.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(44, 10).expect("mobile switcher"); + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 20, + row: 8, + modifiers: KeyModifiers::empty(), + })]); + assert!(wheel.repaint); + assert_eq!(state.mobile_switcher_scroll, 2); + state.compose(44, 10).expect("wheel position stays stable"); + assert_eq!(state.mobile_switcher_scroll, 2); + for _ in 0..7 { + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Down, + KeyModifiers::empty(), + ))]); + } + state.compose(44, 10).expect("revealed mobile selection"); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); + assert!(state.mobile_switcher_scroll > 2); + assert!(state.hits.mobile_targets.iter().any(|(_, target)| { + matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + })); + let close = state.hits.mobile_close; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + + state.compose(80, 20).expect("desktop transition"); + assert!(state.hits.mobile_switch.is_empty()); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); + + state.mode = ClientShellMode::Navigate; + let short = state.compose(44, 2).expect("short mobile switcher"); + assert_eq!(short.cells[0].symbol, "─"); + assert!(state.hits.mobile_close.is_empty()); + assert!(state.hits.mobile_targets.is_empty()); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs new file mode 100644 index 00000000..4cf97152 --- /dev/null +++ b/src/client/shell/tests/mod.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::api::schema::AgentStatus; +use crate::protocol::{ + ClientShellAgent, ClientShellPane, ClientShellTab, ClientShellWorktree, PaneSurfacePane, + PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect, +}; +use crossterm::event::MouseEvent; + +fn snapshot() -> ClientShellSnapshot { + ClientShellSnapshot { + boot_id: "boot-1".into(), + revision: 1, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: "herdr update".into(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: "/tmp/herdr-worktrees".into(), + release_notes: None, + focused_workspace_id: Some("ws_1".into()), + focused_tab_id: Some("tab_1".into()), + focused_pane_id: Some("pane_1".into()), + tab_bar_right: Vec::new(), + tab_bar_right_separator: " ".into(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: vec![ClientShellWorkspace { + workspace_id: "ws_1".into(), + active_tab_id: "tab_1".into(), + new_workspace_cwd: "/repo".into(), + number: 1, + label: "client-shell".into(), + custom_label: false, + branch: Some("main".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: true, + agent_status: AgentStatus::Idle, + }], + tabs: vec![ClientShellTab { + tab_id: "tab_1".into(), + workspace_id: "ws_1".into(), + number: 1, + label: "1".into(), + custom_label: false, + zoomed: false, + focused: true, + agent_status: AgentStatus::Idle, + }], + panes: vec![ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }], + agents: Vec::new(), + commands: Vec::new(), + } +} + +fn worktree_list_result(open_workspace_id: Option<&str>) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::WorktreeList { + source: crate::api::schema::WorktreeSourceInfo { + repo_key: "repo-key".into(), + repo_name: "repo".into(), + repo_root: "/repo".into(), + source_checkout_path: "/repo".into(), + source_workspace_id: Some("ws_1".into()), + }, + worktrees: vec![crate::api::schema::WorktreeInfo { + path: "/repo-feature".into(), + branch: Some("feature".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: open_workspace_id.map(str::to_owned), + label: "repo".into(), + }], + } +} + +fn surface() -> PaneSurfaceFrame { + let surface_buffer = Buffer::with_lines(["LIVE", "PANE"]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &surface_buffer, + Some(crate::protocol::CursorState { + x: 1, + y: 1, + visible: true, + shape: 2, + }), + &[], + ), + panes: vec![PaneSurfacePane { + pane_id: "pane_1".into(), + content_revision: 0, + rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + inner_rect: SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: crate::protocol::SurfaceGraphicsScene::default(), + } +} + +fn pane_scroll_result( + offset_from_bottom: u64, + max_offset_from_bottom: u64, + viewport_rows: u64, +) -> crate::api::schema::ResponseResult { + crate::api::schema::ResponseResult::PaneInfo { + pane: crate::api::schema::PaneInfo { + pane_id: "pane_1".into(), + terminal_id: "terminal_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + focused: true, + cwd: None, + foreground_cwd: None, + label: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + display_agent: None, + agent_status: crate::api::schema::AgentStatus::Unknown, + state_labels: HashMap::new(), + tokens: HashMap::new(), + agent_session: None, + scroll: Some(crate::api::schema::PaneScrollInfo { + offset_from_bottom, + max_offset_from_bottom, + viewport_rows, + }), + revision: 0, + }, + } +} + +fn copy_search_result( + matches: Vec, + current: Option, +) -> crate::api::schema::ResponseResult { + let total = matches.len() as u64; + crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: "pane_1".into(), + content_revision: 0, + matches, + total, + current, + current_global: current.map(u64::from), + } +} + +fn surface_with_popup() -> PaneSurfaceFrame { + let mut surface = surface(); + let popup_buffer = Buffer::with_lines(["popup-live", "", ""]); + surface.popup = Some(Box::new(crate::protocol::ClientShellPopupSurface { + terminal_id: "terminal-popup".into(), + title: "popup title".into(), + width: Some(crate::protocol::ClientShellPopupSize::Cells(12)), + height: Some(crate::protocol::ClientShellPopupSize::Cells(5)), + frame: FrameData::from_ratatui_buffer_with_hyperlinks( + &popup_buffer, + Some(crate::protocol::CursorState { + x: 2, + y: 1, + visible: true, + shape: 1, + }), + &[], + ), + mouse_reporting: true, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })); + surface +} + +mod agents_worktrees_notifications; +mod chrome_context; +mod copy; +#[path = "input.rs"] +mod input_domain; +mod keybindings_settings; +mod mobile; +mod mouse_selection; +mod popup_focus_projection; +mod startup_overlays; diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs new file mode 100644 index 00000000..9734db7c --- /dev/null +++ b/src/client/shell/tests/mouse_selection.rs @@ -0,0 +1,686 @@ +use super::*; + +#[test] +fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else { + panic!("expected link activation request"); + }; + let request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneLinkActivate(params) + if params.pane_id == "pane_1" && params.viewport_row == 1 && params.col == 2 + )); + + let up = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..down + }; + let held = state.handle_raw_events(vec![RawInputEvent::Mouse(up)]); + assert!(held.requests.is_empty() && held.actions.is_empty()); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some("https://example.test".to_owned()), + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test" + )); + assert!(!state.url_click_consumes_until_up); +} + +#[test] +fn ctrl_click_without_a_link_replays_the_original_gesture() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let request_id = match &activate.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + _ => panic!("expected link activation request"), + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ReplayMouse(events)] if events == &vec![down] + )); + let replay = match actions.into_iter().next().expect("replay action") { + ClientShellAction::ReplayMouse(events) => state.replay_mouse_events(events), + _ => unreachable!(), + }; + assert!(matches!( + &replay.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::PaneFocus(_)) + )); + assert!(state.selection.is_some()); +} + +#[test] +fn pane_split_drag_uses_projected_handle_and_stable_tab_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("split pane surface"); + let split = state.hits.pane_splits[0].clone(); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: split.hit_rect.x, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneSplit { .. }) + )); + let mut replacement = snapshot(); + replacement.revision = 2; + replacement + .tab_bar_right + .push(crate::protocol::ClientShellTabStatusSegment { + text: "updated".into(), + accent: false, + }); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + replacement_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: vec![false, true], + }); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &drag.actions[..] else { + panic!("pane split drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::LayoutSetSplitRatio(params) + if params.tab_id.as_deref() == Some("tab_1") + && params.path == vec![false, true] + && (params.ratio - 0.6).abs() < f32::EPSILON + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: split.area.x + 48, + row: split.hit_rect.y + 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn disabled_mouse_chrome_keeps_tab_wheel_but_removes_split_drag_hits() { + let mut config = Config::default(); + config.ui.mouse_capture = false; + let mut projected = snapshot(); + let mut second_tab = projected.tabs[0].clone(); + second_tab.tab_id = "tab_2".into(); + second_tab.number = 2; + second_tab.label = "2".into(); + second_tab.focused = false; + projected.tabs.push(second_tab); + let mut pane_surface = surface(); + pane_surface.splits.push(PaneSurfaceSplit { + direction: PaneSurfaceSplitDirection::Horizontal, + pos: 40, + area: SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 19, + }, + hit_rect: SurfaceRect { + x: 40, + y: 0, + width: 1, + height: 19, + }, + path: Vec::new(), + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("mouse-disabled shell"); + assert!(state.hits.pane_splits.is_empty()); + let first_tab = state.hits.tabs[0].0; + let wheel = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: first_tab.x, + row: first_tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &wheel.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2" + ) + )); +} + +#[test] +fn client_double_click_selects_and_copies_endpoint_row_word() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let release = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![click()]); + state.handle_raw_events(vec![release()]); + let second = state.handle_raw_events(vec![click()]); + let ClientShellAction::Endpoint { request, .. } = second + .actions + .iter() + .find(|action| { + matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + ) + }) + .expect("word-row read") + else { + unreachable!() + }; + let word_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } + && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 3 } + )); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &word_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(repaint); + assert!(state + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_finalized)); + let [ClientShellAction::Endpoint { request, .. }] = &actions[..] else { + panic!("auto-copy should read the selected word"); + }; + let copy_request_id = request.id.clone(); + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor.col == 0 && params.cursor.col == 3 + )); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + ©_request_id, + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: "LIVE".into(), + }), + ); + assert!(matches!( + &actions[..], + [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"LIVE" + )); +} + +#[test] +fn pane_mouse_input_keeps_stable_target_and_endpoint_encoding() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::ALT, + })]); + let [ClientMessage::ClientShellPaneInput { pane_id, events }] = &click.requests[..] else { + panic!("pane application click should use targeted canonical input"); + }; + assert_eq!(pane_id, "pane_1"); + assert!(matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + modifiers, + .. + }] if *modifiers == KeyModifiers::ALT.bits() + )); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(moved.requests.is_empty()); + assert!(state.pane_mouse_gesture.is_some()); + state.hits.panes.clear(); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::ALT, + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_pixel_mouse_preserves_pane_relative_pixel_coordinates() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + pane_surface.panes[0].sgr_pixel_mouse = true; + pane_surface.panes[0].pixel_width = 39; + pane_surface.panes[0].pixel_height = 38; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let geometry = + crate::input::mouse::HostGeometry::new(106, 20, 1060, 400).expect("host geometry"); + let x = u32::from(pane.inner_rect.x) * 10 + 21; + let y = u32::from(pane.inner_rect.y) * 20 + 21; + let report = format!("\x1b[<0;{x};{y}M"); + + let outcome = state.handle_pixel_mouse(report.as_bytes(), geometry); + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Pixels { x: 20, y: 20, .. }, + .. + }] + ) + )); +} + +#[test] +fn pane_owned_right_click_forwards_the_complete_gesture() { + let mut snapshot = snapshot(); + snapshot.panes[0].right_click_passthrough = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); + assert!(state.pane_mouse_gesture.is_some()); + + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Right + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn tab_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + ref tab_id, + insert_index: Some(3), + .. + }) if tab_id == "tab_1" + )); + let frame = state.compose(106, 20).expect("tab drop indicator"); + assert!(frame + .cells + .iter() + .take(frame.width as usize) + .any(|cell| cell.symbol == "│")); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.right().saturating_sub(1), + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &release.actions[..] else { + panic!("tab drag should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::TabMove(params) + if params.tab_id == "tab_1" && params.insert_index == 3 + )); + + state.compose(106, 20).expect("tabs after drag"); + let second = state.hits.tabs[1].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 1, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_2") + )); +} + +#[test] +fn tab_drag_clears_its_drop_target_after_leaving_the_tab_row() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{index}"); + tab.number = index; + tab.label = index.to_string(); + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("three tabs"); + let first = state.hits.tabs[0].0; + let third = state.hits.tabs[2].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 1, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Tab { + insert_index: None, + .. + }) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x, + row: third.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); +} + +#[test] +fn tab_wheel_switches_tabs_without_changing_overflow_scroll() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("tab bar"); + let tab = state.hits.tabs[0].0; + + let outcome = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: tab.x, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::TabFocus(target) if target.tab_id == "tab_1" + ) + )); + assert_eq!(state.tab_scroll, 0); + state.compose(106, 20).expect("tab bar after wheel"); + assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_1")); +} + +#[test] +fn context_menu_keyboard_and_outside_click_are_client_owned() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + let tab = state.hits.tabs[0].0; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: tab.x + 1, + row: tab.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 20).expect("tab context menu"); + let moved = state.handle_input_bytes(b"\x1b[B"); + assert!(moved.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay { + highlighted: 1, + .. + })) + )); + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "not pane input", + ))]); + assert!(text.requests.is_empty()); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("not pane input".into())]); + assert!(paste.requests.is_empty()); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 105, + row: 19, + modifiers: KeyModifiers::empty(), + })]); + assert!(outside.repaint); + assert!(state.overlay.is_none()); +} diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs new file mode 100644 index 00000000..6ccdcf6e --- /dev/null +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -0,0 +1,1226 @@ +use super::*; + +#[test] +fn clipboard_image_targets_the_focused_pane_or_active_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Pane( + "pane_1".into() + )) + ); + + state.mode = ClientShellMode::Prefix; + assert_eq!(state.clipboard_image_target(), None); + state.mode = ClientShellMode::Terminal; + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); + state.overlay = None; + + state.set_pane_surface(surface_with_popup()); + assert_eq!( + state.clipboard_image_target(), + Some(crate::protocol::ClientClipboardImageTarget::Popup( + "terminal-popup".into() + )) + ); + state.overlay = Some(ClientShellOverlay::Onboarding); + assert_eq!(state.clipboard_image_target(), None); +} + +#[test] +fn modal_paste_target_requires_a_focused_editable_client_field() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + assert!(!state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay { + title: "rename pane", + input: String::new(), + replace_on_type: false, + target: ClientRenameTarget::Pane { + pane_id: "pane_1".into(), + }, + })); + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: "ws_1".into(), + repo_name: "repo".into(), + branch: String::new(), + checkout_path: String::new(), + replace_on_type: false, + error: None, + creating: true, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeCreate(create)) = state.overlay.as_mut() { + create.creating = false; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: "ws_1".into(), + entries: Vec::new(), + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() { + open.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { + query: String::new(), + search_focused: false, + selected: 0, + scroll: 0, + filter: None, + expanded_workspaces: HashSet::new(), + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + assert!(!state.modal_paste_target_active()); + if let Some(ClientShellOverlay::Help(help)) = state.overlay.as_mut() { + help.search_focused = true; + } + assert!(state.modal_paste_target_active()); + + state.overlay = None; + state.copy_mode = Some(ClientCopyModeState { + pane_id: "pane_1".into(), + content_revision: 0, + geometry: (80, 24), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + offset_from_bottom: 0, + max_offset_from_bottom: 0, + entry_offset_from_bottom: 0, + selection: None, + search_prompt: Some(ClientCopySearchPrompt { + direction: crate::api::schema::PaneCopySearchDirection::Forward, + query: String::new(), + }), + search_query: String::new(), + search_direction: None, + search_matches: Vec::new(), + search_total: 0, + search_current: None, + search_current_global: None, + search_generation: 0, + copy_after_search: false, + }); + assert!(state.modal_paste_target_active()); + state.popup_pending = true; + assert!(!state.modal_paste_target_active()); +} + +#[test] +fn non_overlay_ctrl_v_is_forwarded_to_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let key = crate::input::TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + + let outcome = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + + assert!(matches!( + &outcome.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!(&events[..], [ClientPaneInputEvent::Key { .. }]) + )); +} + +#[test] +fn desktop_composition_keeps_shell_outside_origin_relative_surface() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("composed 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("spaces")); + assert!(text.contains("client-shell")); + assert!(text.contains("main")); + assert!(text.contains("LIVE")); + assert!(!text.contains("1 1")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((27, 2)) + ); +} + +#[test] +fn client_composes_popup_terminal_content_inside_client_owned_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 20).expect("popup frame"); + let popup = state.hits.popup.as_ref().expect("popup hit geometry"); + assert_eq!(popup.rect.width, 12); + assert_eq!(popup.rect.height, 5); + assert_eq!(popup.inner_rect.width, 9); + assert_eq!(popup.inner_rect.height, 3); + 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("popup tit")); + assert!(text.contains("popup-liv")); + assert_eq!( + frame.cursor.as_ref().map(|cursor| (cursor.x, cursor.y)), + Some((popup.inner_rect.x + 2, popup.inner_rect.y + 1)) + ); +} + +#[test] +fn popup_owns_keys_text_paste_and_mouse_before_shell_controls() { + 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).expect("popup frame"); + + for bytes in [b"x".as_slice(), b"\x02".as_slice(), b"\x1b".as_slice()] { + let input = state.handle_input_bytes(bytes); + assert!(matches!( + &input.requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); + } + + let text = state.handle_raw_events(vec![RawInputEvent::Text(crate::input::TextCommit::new( + "ime", + ))]); + assert!(matches!( + &text.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::TextCommit(value)] if value == "ime") + )); + let paste = state.handle_raw_events(vec![RawInputEvent::Paste("paste".into())]); + assert!(matches!( + &paste.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::Paste(value)] if value == "paste") + )); + + let popup = state.hits.popup.as_ref().expect("popup hit").clone(); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.inner_rect.x + 3, + row: popup.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &mouse.requests[..], + [ClientMessage::ClientShellPopupInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + position: ClientMousePosition::Cell { column: 3, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_some()); + state.set_pane_surface(surface()); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_transition_dismisses_client_overlays_and_restores_pane_input_after_close() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Help), + &mut ClientShellInput::default(), + ); + assert!(state.overlay.is_some()); + + state.set_pane_surface(surface_with_popup()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let popup_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &popup_input.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let pane_input = state.handle_input_bytes(b"p"); + assert!(matches!( + &pane_input.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn popup_target_survives_surface_invalidation_during_resize() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + state.invalidate_pane_surface(); + + assert!(matches!( + &state.handle_input_bytes(b"x").requests[..], + [ClientMessage::ClientShellPopupInput { terminal_id, .. }] + if terminal_id == "terminal-popup" + )); + let mouse = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(mouse.requests.is_empty()); + assert!(mouse.actions.is_empty()); +} + +#[test] +fn popup_close_reprocesses_held_key_repeats_into_the_focused_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface_with_popup()); + + let press = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::empty(), + ))]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPopupInput { .. }] + )); + + state.set_pane_surface(surface()); + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(matches!( + &repeat.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, .. }] if pane_id == "pane_1" + )); +} + +#[test] +fn pending_popup_suppresses_held_pane_repeats_but_preserves_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + state.popup_pending = true; + let repeat = state.handle_raw_events(vec![RawInputEvent::Key( + key.clone() + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeat.requests.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); +} + +#[test] +fn prefix_input_source_changes_are_client_owned_and_focus_safe() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.switch_ascii_input_source_in_prefix = true; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + assert!(state.take_input_source_changes().is_empty()); + + let prefix = crate::input::TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + state.handle_raw_events(vec![RawInputEvent::Key(prefix)]); + assert_eq!(state.take_input_source_changes(), vec![true]); + + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(state.take_input_source_changes().is_empty()); + let escape = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape.clone())]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert_eq!(state.take_input_source_changes(), vec![false]); + + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + ))]); + assert_eq!(state.take_input_source_changes(), vec![true]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(state.take_input_source_changes().is_empty()); + state.handle_raw_events(vec![RawInputEvent::Key(escape)]); + assert_eq!(state.take_input_source_changes(), vec![false]); +} + +#[test] +fn focus_loss_releases_held_pane_keys_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_generated_text(Some("x".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x58, + virtual_scan_code: 0x2d, + unicode: 'x' as u16, + control_key_state: 0, + }); + let press = state.handle_raw_events(vec![RawInputEvent::Key(key)]); + assert!(matches!( + &press.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { events, .. }, + ClientMessage::ClientShellFocus { focused: false } + ] if matches!( + &events[..], + [ClientPaneInputEvent::Key { + kind: crate::protocol::ClientKeyKind::Release, + .. + }] + ) + )); + assert!(state.input_leases.is_empty()); +} + +#[test] +fn focus_loss_releases_active_pane_mouse_before_reporting_focus() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + let lost = state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + assert!(matches!( + &lost.requests[..], + [ + ClientMessage::ClientShellPaneInput { pane_id, events }, + ClientMessage::ClientShellFocus { focused: false } + ] if pane_id == "pane_1" && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + position: ClientMousePosition::Cell { column: 2, row: 1 }, + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn focus_gain_reports_focus_and_honors_redraw_policy() { + let mut config = Config::default(); + config.ui.redraw_on_focus_gained = false; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let gained = state.handle_raw_events(vec![RawInputEvent::OuterFocusGained]); + assert!(!gained.repaint); + assert!(gained.query_host_appearance); + assert!(matches!( + &gained.requests[..], + [ClientMessage::ClientShellFocus { focused: true }] + )); +} + +#[test] +fn pane_mouse_release_survives_popup_open_transition() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x, + row: pane.inner_rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &down.requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + assert!(state.pane_mouse_gesture.is_some()); + + state.set_pane_surface(surface_with_popup()); + let up = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &up.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn popup_command_blocks_underlying_input_until_surface_or_error() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"not-for-pane") + .requests + .is_empty()); + assert!(state + .handle_raw_events(vec![RawInputEvent::Paste("secret".into())]) + .requests + .is_empty()); + + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + let (repaint, _) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("command_failed".into()), + message: "popup failed".into(), + }), + ); + assert!(repaint); + assert!(!state.popup_pending); + assert!(matches!( + &state.handle_input_bytes(b"p").requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "secret-popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut invoke = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut invoke); + let request_id = match &invoke.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + other => panic!("expected popup command request, got {other:?}"), + }; + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert!(state.popup_pending); + assert!(state + .handle_input_bytes(b"still-blocked") + .requests + .is_empty()); + let deadline = state.popup_pending_deadline.expect("pending timeout"); + state.tick_popup_pending(deadline); + assert!(!state.popup_pending); +} + +#[test] +fn shell_refuses_mismatched_projection_and_clears_stale_hits_in_either_order() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + assert!(!state.hits.panes.is_empty()); + + let mut replacement = snapshot(); + replacement.revision = 2; + state.set_snapshot(Box::new(replacement)); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("initial frame"); + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.set_pane_surface(replacement_surface); + assert!(state.hits.panes.is_empty()); + assert!(state.compose(106, 20).is_none()); +} + +#[test] +fn shell_ignores_older_same_boot_snapshot_and_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut current_snapshot = snapshot(); + current_snapshot.revision = 2; + current_snapshot.workspaces[0].label = "current".into(); + state.set_snapshot(Box::new(current_snapshot)); + let mut current_surface = surface(); + current_surface.projection_revision = 2; + current_surface.frame.cells[0].symbol = "N".into(); + state.set_pane_surface(current_surface); + state.compose(106, 20).expect("current shell"); + assert!(!state.hits.panes.is_empty()); + let held_key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()); + assert!(matches!( + &state + .handle_raw_events(vec![RawInputEvent::Key(held_key.clone())]) + .requests[..], + [ClientMessage::ClientShellPaneInput { .. }] + )); + + let mut stale_snapshot = snapshot(); + stale_snapshot.workspaces[0].label = "stale".into(); + state.set_snapshot(Box::new(stale_snapshot)); + let mut stale_surface = surface(); + stale_surface.frame.cells[0].symbol = "O".into(); + state.set_pane_surface(stale_surface); + + let installed_snapshot = state.snapshot.as_deref().expect("current snapshot"); + assert_eq!(installed_snapshot.revision, 2); + assert_eq!(installed_snapshot.workspaces[0].label, "current"); + let installed_surface = state.pane_surface.as_ref().expect("current pane surface"); + assert_eq!(installed_surface.projection_revision, 2); + assert_eq!(installed_surface.frame.cells[0].symbol, "N"); + + let mut ahead_surface = surface(); + ahead_surface.projection_revision = 4; + ahead_surface.frame.cells[0].symbol = "A".into(); + state.set_pane_surface(ahead_surface); + let mut delayed_surface = surface(); + delayed_surface.projection_revision = 3; + delayed_surface.frame.cells[0].symbol = "D".into(); + state.set_pane_surface(delayed_surface); + let installed_surface = state.pane_surface.as_ref().expect("newest pane surface"); + assert_eq!(installed_surface.projection_revision, 4); + assert_eq!(installed_surface.frame.cells[0].symbol, "A"); + + let mut replacement_boot = snapshot(); + replacement_boot.boot_id = "boot-2".into(); + state.set_snapshot(Box::new(replacement_boot)); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + held_key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!( + release.requests.is_empty(), + "boot replacement must discard held input leases" + ); + let mut prior_boot_surface = surface(); + prior_boot_surface.projection_revision = u64::MAX; + state.set_pane_surface(prior_boot_surface); + assert!( + state.pane_surface.is_none(), + "an old endpoint surface must not cross the boot boundary" + ); +} + +#[test] +fn resize_invalidation_drops_stale_hits_but_preserves_gesture_release() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.pane_mouse_gesture.is_some()); + + state.invalidate_pane_surface(); + assert!(state.pane_surface.is_none()); + assert!(state.hits.panes.is_empty()); + let stale_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(stale_click.requests.is_empty()); + assert!(stale_click.actions.is_empty()); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.requests[..], + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" + && matches!( + &events[..], + [ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Up( + crate::protocol::ClientMouseButton::Left + ), + .. + }] + ) + )); + assert!(state.pane_mouse_gesture.is_none()); +} + +#[test] +fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scrollbar_rect = Some(SurfaceRect { + x: 3, + y: 0, + width: 1, + height: 2, + }); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let pane = state.hits.panes[0].clone(); + let track = pane.scrollbar_rect.expect("scrollbar track"); + let metrics = pane.scroll.expect("scroll metrics"); + + let track_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_row(metrics, track, track.y); + assert!(track_click.requests.is_empty()); + assert!(matches!( + &track_click.actions[..], + [ + ClientShellAction::Endpoint { request: focus, .. }, + ClientShellAction::Endpoint { request: scroll, .. } + ] if matches!( + &focus.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) && matches!( + &scroll.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let track_scroll_id = match &track_click.actions[1] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!(), + }; + state.handle_endpoint_result( + "boot-1", + &track_scroll_id, + Ok(pane_scroll_result(expected as u64, 20, 2)), + ); + + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + let thumb_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &thumb_down.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + ) + )); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::PaneScrollbar { .. }) + )); + + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.y, + modifiers: KeyModifiers::empty(), + })]); + let expected = crate::ui::scrollbar_offset_from_drag_row(metrics, track, track.y, 0); + assert!(matches!( + &drag.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" + && params.offset_from_bottom == expected as u64 + ) + )); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(release.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::EditScrollback), + &mut input, + ); + + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneEditScrollback(target) + if target.pane_id == "pane_1" + ) + )); +} + +#[test] +fn sidebar_scrollbars_use_proportional_shared_geometry_and_drag() { + let mut projected = snapshot(); + for index in 2..=10 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + for index in 1..=10 { + projected.agents.push(crate::protocol::ClientShellAgent { + pane_id: format!("agent-pane-{index}"), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(format!("agent-{index}")), + display_agent: None, + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: index, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("overflowing sidebars"); + + for agent in [false, true] { + let (track, metrics) = if agent { + ( + state.hits.agent_scrollbar, + state.hits.agent_scroll_metrics.expect("agent metrics"), + ) + } else { + ( + state.hits.workspace_scrollbar, + state + .hits + .workspace_scroll_metrics + .expect("workspace metrics"), + ) + }; + assert!(track.width > 0); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("scrollbar thumb"); + assert!(thumb.len > 1); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::empty(), + })]); + let dragged = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragged.repaint); + if agent { + assert_eq!(state.agent_scroll, metrics.max_offset_from_bottom); + } else { + assert_eq!(state.workspace_scroll, metrics.max_offset_from_bottom); + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + } +} + +#[test] +fn popup_preemption_cancels_settings_theme_preview() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_settings_overlay(); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + + state.set_pane_surface(surface_with_popup()); + + assert!(!matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette, original_palette); +} + +#[test] +fn pending_scroll_target_does_not_relabel_an_older_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + let mut outcome = ClientShellInput::default(); + state.push_pane_scroll_offset("pane_1".into(), 10, &mut outcome); + state.set_pane_surface(pane_surface); + assert_eq!( + state + .pane_surface + .as_ref() + .and_then(|surface| surface.panes[0].scroll) + .map(|scroll| scroll.offset_from_bottom), + Some(0) + ); + assert_eq!(state.pane_scroll_targets.get("pane_1"), Some(&10)); +} + +#[test] +fn retained_surface_patch_updates_only_pane_cells_without_recomposing_chrome() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + 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![ + crate::protocol::CellData { + symbol: "N".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }; + 4 + ], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let pane_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x); + assert_eq!(patched.cells[pane_index].symbol, "N"); + assert_eq!( + patched.cells[0], composed.cells[0], + "sidebar chrome changed" + ); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 2); + + let mut forced_full = surface(); + forced_full.surface_revision = 3; + forced_full.frame.cells[0].symbol = "F".into(); + state.set_pane_surface(forced_full); + assert_eq!(state.pane_surface.as_ref().unwrap().surface_revision, 3); + assert_eq!( + state.pane_surface.as_ref().unwrap().frame.cells[0].symbol, + "F" + ); +} + +#[test] +fn retained_surface_patch_recomposes_client_owned_mode_and_diagnostic_rows() { + for diagnostic in [false, true] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let pane_surface = surface(); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.content_revision = 2; + state.set_pane_surface(pane_surface); + state.compose(100, 30).expect("initial composed frame"); + if diagnostic { + state.config_diagnostic = Some("invalid config".into()); + } else { + state.mode = ClientShellMode::Prefix; + } + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: Vec::new(), + panes: vec![updated_pane], + cursor: None, + }; + + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + } +} + +#[test] +fn retained_surface_patch_updates_scrollbar_cells_and_pane_hit_metadata() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &Buffer::with_lines(["LIVE ", "PANE "]), + None, + &[], + ); + pane_surface.panes[0].rect.width = 5; + state.set_pane_surface(pane_surface.clone()); + let composed = state.compose(100, 30).expect("initial composed frame"); + let layout = state.layout(100, 30); + let mut updated_pane = pane_surface.panes[0].clone(); + updated_pane.scrollbar_rect = Some(SurfaceRect { + x: 4, + y: 0, + width: 1, + height: 2, + }); + updated_pane.scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 2, + max_offset_from_bottom: 8, + viewport_rows: 2, + }); + updated_pane.mouse_reporting = true; + updated_pane.sgr_pixel_mouse = true; + 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: 4, + y: 0, + cells: vec![crate::protocol::CellData { + symbol: "▐".into(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + }], + }], + panes: vec![updated_pane], + cursor: None, + }; + + let ClientPaneSurfacePatchOutcome::Applied(Some(patch)) = state.apply_pane_surface_patch(patch) + else { + panic!("expected fast retained patch"); + }; + let patched = apply_composed_surface_patch(&composed, patch).expect("apply composed patch"); + let scrollbar_index = usize::from(layout.pane_surface.y) * usize::from(patched.width) + + usize::from(layout.pane_surface.x + 4); + assert_eq!(patched.cells[scrollbar_index].symbol, "▐"); + let hit = state + .hits + .panes + .iter() + .find(|hit| hit.pane_id == "pane_1") + .expect("pane hit"); + assert_eq!( + hit.scrollbar_rect, + Some(Rect::new( + layout.pane_surface.x + 4, + layout.pane_surface.y, + 1, + 2, + )) + ); + assert_eq!( + hit.scroll.map(|scroll| scroll.max_offset_from_bottom), + Some(8) + ); + assert!(hit.mouse_reporting); + assert!(hit.sgr_pixel_mouse); +} + +#[test] +fn retained_surface_patch_rejects_stale_base_without_mutating_surface() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let before = state.pane_surface.clone(); + let outcome = state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 0, + surface_revision: 2, + rows: Vec::new(), + panes: Vec::new(), + cursor: None, + }); + assert!(matches!(outcome, ClientPaneSurfacePatchOutcome::Rejected)); + assert_eq!(state.pane_surface, before); +} diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs new file mode 100644 index 00000000..40ba0bd6 --- /dev/null +++ b/src/client/shell/tests/startup_overlays.rs @@ -0,0 +1,1259 @@ +use super::*; + +#[test] +fn endpoint_product_announcement_is_client_rendered_modal_and_dismissed_by_identity() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "A client-owned announcement".into(), + body: (0..40) + .map(|index| format!("- announcement line {index}")) + .collect::>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface_with_popup()); + + let frame = state.compose(106, 30).expect("announcement 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("A client-owned announcement")); + assert!(text.contains("product announcement · v0.8.2")); + assert!(!state.hits.product_announcement_scrollbar.is_empty()); + + let popup_key = state.handle_input_bytes(b"x"); + assert!(popup_key.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + state.handle_input_bytes(b"\x1b[6~"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll: 11, .. } + )) + )); + + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ProductAnnouncementDismiss(params) + if params.version == "0.8.2" && params.id == "client-shell" + ) + )); + + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(state.overlay.is_none(), "same announcement stays dismissed"); + endpoint_snapshot + .product_announcement + .as_mut() + .expect("announcement") + .id = "new-announcement".into(); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + state.chrome_drag = Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset: 0 }); + endpoint_snapshot.product_announcement = None; + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(state.overlay.is_none()); + assert!(state.chrome_drag.is_none()); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn failed_product_announcement_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_announcement".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + assert!(state.dismissed_product_announcement.is_none()); +} + +#[test] +fn release_notes_reconcile_and_failed_dismiss_reopens_authoritative_snapshot() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "first notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.open_release_notes(); + + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.4".into(), + body: "second notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.4" + )); + + let dismissed = state.handle_input_bytes(b"\r"); + let request_id = match &dismissed.actions[..] { + [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), + actions => panic!("unexpected actions: {actions:?}"), + }; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.5".into(), + body: "current notes".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("stale_release_notes".into()), + message: "dismiss failed".into(), + }), + ); + assert!(repaint); + assert!(actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { ref version, .. } + )) if version == "0.8.5" + )); +} + +#[test] +fn product_announcement_mouse_is_modal_and_closes_only_from_its_button() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: (0..40) + .map(|index| format!("- line {index}")) + .collect::>() + .join("\n"), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface_with_popup()); + state.compose(106, 30).expect("announcement frame"); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(outside.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + )); + + let metrics = state + .hits + .product_announcement_scroll_metrics + .expect("scroll metrics"); + let track = state.hits.product_announcement_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ProductAnnouncement( + crate::app::state::ProductAnnouncementState { scroll, .. } + )) if usize::from(scroll) == state.hits.product_announcement_max_scroll + )); + + let close = state.hits.overlay_primary; + let closed = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ProductAnnouncementDismiss(_)) + )); +} + +#[test] +fn onboarding_has_priority_over_endpoint_product_announcement() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.product_announcement = + Some(crate::protocol::ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Hidden until a later launch".into(), + body: "announcement body".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); +} + +#[test] +fn startup_onboarding_is_client_rendered_and_modal() { + let config = ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + let early = state.handle_input_bytes(b"\r"); + assert!(early.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("onboarding 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("terminal workspace manager for coding agents")); + assert!(text.contains("this is a mouse-first terminal")); + assert!(text.contains("ctrl+b enters prefix mode")); + assert!(text.contains("install optional agent integrations")); + assert_eq!(state.hits.overlay_primary.width, 12); + + let ignored = state.handle_input_bytes(b"x"); + assert!(ignored.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.actions.is_empty()); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + + state.set_pane_surface(surface_with_popup()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Onboarding) + )); + let popup_input = state.handle_input_bytes(b"hidden-popup-input"); + assert!(popup_input.requests.is_empty()); + let popup_paste = state.handle_raw_events(vec![RawInputEvent::Paste("secret".into())]); + assert!(popup_paste.requests.is_empty()); +} + +#[test] +fn onboarding_completion_persists_and_opens_endpoint_integrations() { + let path = std::env::temp_dir().join(format!( + "herdr-client-onboarding-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::write(&path, "[terminal]\ndefault_shell = \"fish\"\n") + .expect("write onboarding config"); + let onboarding_config = || { + let mut config = + ClientShellConfig::from_config(&Config::default()).with_startup_onboarding(true); + config.local_config_path = path.clone(); + config + }; + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(b"\r"); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + loading_integrations: true, + .. + })) + )); + assert!(matches!( + &outcome.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + let persisted = std::fs::read_to_string(&path).expect("read onboarding config"); + assert!(persisted.contains("onboarding = false")); + assert!(persisted.contains("default_shell = \"fish\"")); + + for input in [b"\x1b[C".as_slice(), b"l".as_slice()] { + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let outcome = state.handle_input_bytes(input); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(outcome.actions.len(), 1); + } + + let config = onboarding_config(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("onboarding mouse frame"); + let button = state.hits.overlay_primary; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: button.x, + row: button.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(click.actions.len(), 1); + + let unreadable_path = path.with_extension("dir"); + std::fs::create_dir(&unreadable_path).expect("create unreadable config path"); + let mut config = onboarding_config(); + config.local_config_path = unreadable_path.clone(); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let failed_write = state.handle_input_bytes(b"\r"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Integrations, + .. + })) + )); + assert_eq!(failed_write.actions.len(), 1); + assert!(state + .config_diagnostic + .as_deref() + .is_some_and(|diagnostic| diagnostic.contains("failed to read config"))); + assert!(unreadable_path.is_dir()); + std::fs::remove_dir(&unreadable_path).expect("remove unreadable config path"); + + std::fs::remove_file(path).expect("remove onboarding config"); +} + +#[test] +fn startup_config_diagnostics_are_client_rendered_and_persist_until_replaced() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_startup_config_diagnostic(Some("local config warning".into())); + let mut state = ClientShellState::new(config); + let mut shared_snapshot = snapshot(); + shared_snapshot.config_diagnostic = Some("local config warning".into()); + state.set_snapshot(Box::new(shared_snapshot)); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("client + endpoint: local config warning") + ); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint config warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 20).expect("diagnostic 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("client: local config warning")); + assert!(text.contains("endpoint: endpoint config warning")); + + state.handle_input_bytes(b"x"); + assert!(state.config_diagnostic.is_some()); + + state.set_snapshot(Box::new(snapshot())); + assert_eq!( + state.config_diagnostic.as_deref(), + Some("local config warning") + ); +} + +#[test] +fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("one-line warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.visible_notification = Some(ClientVisibleNotification { + event: SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "notification".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopRight), + }, + deadline: std::time::Instant::now(), + }); + + state.compose(106, 20).expect("one-line frame"); + let pane_area = state.layout(106, 20).pane_surface; + assert_eq!(state.hits.notification_toast.y, pane_area.y); + let targetless_hit = state.hits.notification_toast; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: targetless_hit.x, + row: targetless_hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.visible_notification.is_some()); + + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("first warning\nsecond warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.compose(106, 20).expect("two-line frame"); + assert_eq!(state.hits.notification_toast.y, pane_area.y); + + state + .visible_notification + .as_mut() + .expect("visible notification") + .event + .position = Some(crate::config::ToastHerdrPosition::BottomRight); + state.compose(106, 20).expect("bottom notification frame"); + assert_eq!(state.hits.notification_toast.bottom(), 19); +} + +#[test] +fn endpoint_reload_result_does_not_override_snapshot_diagnostic_authority() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.config_diagnostic = Some("endpoint warning".into()); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.pending_requests.insert( + "reload-1".into(), + PendingEndpointRequest { + boot_id: "boot-1".into(), + confirmation_workspace_id: None, + kind: PendingEndpointKind::ReloadConfig, + }, + ); + + state.handle_endpoint_result( + "boot-1", + "reload-1", + Ok(crate::api::schema::ResponseResult::ConfigReload { + status: crate::config::ConfigReloadStatus::Partial, + diagnostics: vec!["keybinding warning".into()], + }), + ); + assert_eq!(state.config_diagnostic.as_deref(), Some("endpoint warning")); + + state.set_snapshot(Box::new(snapshot())); + assert!(state.config_diagnostic.is_none()); +} + +#[test] +fn endpoint_keybindings_hide_only_local_keybinding_diagnostics() { + let config = ClientShellConfig::from_config(&Config::default()) + .with_keybinding_source(ClientShellKeybindingSource::Endpoint); + let diagnostics = vec![ + "unsafe direct keybinding: keys.close_pane would intercept typing".into(), + "theme warning".into(), + ]; + + assert!(config.local_config_diagnostic(&diagnostics[..1]).is_none()); + assert!(config.local_config_diagnostic(&diagnostics).is_some()); +} + +#[test] +fn live_client_config_keeps_sound_diagnostics() { + let mut shell_config = ClientShellConfig::from_config(&Config::default()); + let mut config = Config::default(); + config.ui.sound.path = Some(std::path::PathBuf::from("invalid.wav")); + + let diagnostics = shell_config.apply_live_config(&config, &[], &[]); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("expected an mp3 file"))); +} + +#[test] +fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot.clone())); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 8); + let launcher = state.hits.global_launcher; + let shell_buffer = shell.to_ratatui_buffer().expect("shell buffer"); + let badge_x = launcher.right().saturating_sub(6); + assert_eq!( + shell_buffer[(badge_x, launcher.y)].fg, + state.config.palette.accent + ); + assert_eq!( + shell_buffer[(badge_x + 2, launcher.y)].fg, + state.config.palette.overlay0 + ); + + state.sidebar_collapsed = true; + let collapsed = state.compose(106, 30).expect("collapsed update shell"); + let collapsed_buffer = collapsed.to_ratatui_buffer().expect("collapsed buffer"); + assert_eq!( + collapsed_buffer[(state.hits.sidebar_toggle.x, state.hits.sidebar_toggle.y)].fg, + state.config.palette.accent + ); + state.sidebar_collapsed = false; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("navigate update status"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(navigate_text.contains("update ready")); + state.mode = ClientShellMode::Prefix; + let prefix = state + .compose(106, 30) + .expect("prefix without update status"); + let prefix_text = prefix + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(!prefix_text.contains("update ready")); + state.mode = ClientShellMode::Navigate; + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("update menu"); + let text = menu + .cells + .chunks(menu.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("● update ready")); + let update_row = state.hits.global_menu_rows[3].0; + assert_eq!(update_row.width, 16); + let menu_buffer = menu.to_ratatui_buffer().expect("menu buffer"); + assert_eq!( + menu_buffer[(update_row.x + 1, update_row.y)].fg, + state.config.palette.accent + ); + assert_eq!( + menu_buffer[(update_row.x + 3, update_row.y)].fg, + state.config.palette.text + ); + state.activate_global_menu_item(3, &mut ClientShellInput::default()); + let notes = state.compose(106, 30).expect("release notes"); + let bottom_row_start = usize::from(notes.width) * usize::from(notes.height - 1); + let bottom_row = notes.cells[bottom_row_start..] + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(!bottom_row.contains("NAVIGATE")); + let text = notes + .cells + .chunks(notes.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(text.contains("v0.8.3")); + assert!(text.contains("update ready")); + assert!(text.contains("detach, run herdr update")); + assert!(!state.hits.release_notes_scrollbar.is_empty()); + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + assert_eq!( + state.hits.overlay_primary, + crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )) + ); + let notes_buffer = notes.to_ratatui_buffer().expect("release notes buffer"); + let title_cell = ¬es_buffer[(stack.header.x + 1, stack.header.y)]; + assert_eq!(title_cell.fg, state.config.palette.text); + assert!(title_cell.modifier.contains(Modifier::BOLD)); + assert_eq!( + notes_buffer[(state.hits.overlay_primary.x, state.hits.overlay_primary.y)].bg, + state.config.palette.accent + ); + + let outside = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(outside.requests.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes(_)) + )); + let pane_text = state.handle_raw_events(vec![ + RawInputEvent::Text(crate::input::TextCommit::new("ime")), + RawInputEvent::Paste("secret".into()), + ]); + assert!(pane_text.requests.is_empty()); + + let metrics = state + .hits + .release_notes_scroll_metrics + .expect("release notes scroll metrics"); + let track = state.hits.release_notes_scrollbar; + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("release notes thumb"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: thumb.top, + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + })]); + assert!(state.chrome_drag.is_none()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if usize::from(scroll) == state.hits.release_notes_max_scroll + )); + if let Some(ClientShellOverlay::ReleaseNotes(notes)) = state.overlay.as_mut() { + notes.scroll = 0; + } + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let repeated = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::PageDown, KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Repeat), + )]); + assert!(repeated.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 3, .. } + )) + )); + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(matches!( + &dismissed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::ReleaseNotesDismiss(params) + if params.version == "0.8.3" + ) + )); + + endpoint_snapshot.boot_id = "boot-2".into(); + endpoint_snapshot.revision = 2; + endpoint_snapshot.update_available = None; + endpoint_snapshot + .release_notes + .as_mut() + .expect("release notes") + .preview = false; + state.set_snapshot(Box::new(endpoint_snapshot)); + let mut installed_surface = surface(); + installed_surface.boot_id = "boot-2".into(); + installed_surface.projection_revision = 2; + state.set_pane_surface(installed_surface); + state.compose(106, 30).expect("installed shell"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let installed = state.compose(106, 30).expect("installed menu"); + let installed_text = installed + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(installed_text.contains("what's new")); + assert!(!installed_text.contains("● what's new")); +} + +#[test] +fn coalesced_release_notes_open_and_scroll_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::>() + .join("\n"), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::PageDown, + KeyModifiers::empty(), + )), + ]); + + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll: 8, .. } + )) + )); +} + +#[test] +fn coalesced_release_notes_open_and_mouse_uses_current_geometry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let body = (0..40) + .map(|index| format!("- release line {index}")) + .collect::>() + .join("\n"); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: body.clone(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("initial shell"); + + let outer = crate::ui::centered_popup_rect( + Rect::new(0, 0, 106, 30), + crate::ui::RELEASE_NOTES_MODAL_SIZE.0, + crate::ui::RELEASE_NOTES_MODAL_SIZE.1, + ) + .expect("release notes outer"); + let inner = Rect::new(outer.x + 1, outer.y + 1, outer.width - 2, outer.height - 2); + let stack = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1); + let notes = crate::app::state::ReleaseNotesState { + version: "0.8.3".into(), + body, + scroll: 0, + preview: true, + }; + let metrics = crate::ui::release_notes_scroll_metrics( + ¬es, + "herdr update", + stack.content, + &state.config.palette, + ); + let track = crate::ui::release_notes_scrollbar_rect(stack.content, metrics) + .expect("release notes track"); + let close = crate::ui::release_notes_close_button_rect(Rect::new( + stack.header.x, + stack.header.y, + stack.header.width, + 1, + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: track.x, + row: track.bottom().saturating_sub(1), + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { scroll, .. } + )) if scroll > 0 + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 3, + })); + let closed = state.handle_raw_events(vec![ + RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Enter, + KeyModifiers::empty(), + )), + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: close.x, + row: close.y, + modifiers: KeyModifiers::NONE, + }), + ]); + assert!(state.overlay.is_none()); + assert!(matches!( + &closed.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::ReleaseNotesDismiss(_)) + )); +} + +#[test] +fn outdated_integration_badges_launcher_settings_and_settings_tab() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.integration_updates_available = true; + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + let shell = state.compose(106, 30).expect("integration attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + let shell_text = shell + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(shell_text.contains("● menu")); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("integration attention menu"); + let menu_text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(menu_text.contains("● settings")); + assert!(!menu_text.contains("update ready")); + + state.activate_global_menu_item(0, &mut ClientShellInput::default()); + let settings = state.compose(106, 30).expect("settings integration badge"); + let settings_text = settings + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(settings_text.contains("● integrations")); + let integrations_tab = state + .hits + .settings_tabs + .iter() + .find(|(_, section)| *section == ClientSettingsSection::Integrations) + .map(|(rect, _)| *rect) + .expect("integrations tab"); + let settings_buffer = settings.to_ratatui_buffer().expect("settings buffer"); + assert_eq!( + settings_buffer[(integrations_tab.x + 1, integrations_tab.y)].fg, + state.config.palette.accent + ); + assert_eq!( + settings_buffer[(integrations_tab.x + 3, integrations_tab.y)].fg, + state.config.palette.overlay1 + ); +} + +#[test] +fn combined_update_and_integration_attention_preserves_both_badges() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.update_available = Some("0.8.3".into()); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.integration_updates_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Both attention states".into(), + preview: true, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("combined attention shell"); + assert_eq!(state.hits.global_launcher.width, 8); + + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("combined attention menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + let settings = text.find("● settings").expect("settings badge"); + let update = text.find("● update ready").expect("update badge"); + assert!(settings < update); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let navigate = state.compose(106, 30).expect("combined attention navigate"); + let navigate_text = navigate + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(navigate_text.contains("update ready")); +} + +#[test] +fn current_release_notes_use_whats_new_without_attention_badge() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = true; + endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.2".into(), + body: "### Changed\n- Client shell".into(), + preview: false, + }); + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + assert_eq!(state.hits.global_launcher.width, 6); + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("what's new menu"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(text.contains("what's new")); +} + +#[test] +fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overlay() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 0, + })); + let open = state.handle_input_bytes(b"\r"); + assert!(open.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Theme, + .. + })) + )); + let original_theme = state.config.theme_name.clone(); + let original_palette = state.config.palette.clone(); + state.handle_input_bytes(b"j"); + assert_ne!(state.config.theme_name, original_theme); + assert_ne!(state.config.palette.accent, original_palette.accent); + state.handle_input_bytes(b"\x1b"); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.handle_input_bytes(b"j"); + state.handle_input_bytes(b"\t"); + state + .compose(106, 30) + .expect("settings outside-click geometry"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.overlay.is_none()); + assert_eq!(state.config.theme_name, original_theme); + assert_eq!(state.config.palette.accent, original_palette.accent); + + state.open_settings_overlay(); + state.compose(106, 30).expect("settings overlay"); + for _ in 0..3 { + let next = state.handle_input_bytes(b"\t"); + assert!(next.actions.is_empty()); + } + let integrations = state.handle_input_bytes(b"\t"); + let [ClientShellAction::Endpoint { request, .. }] = &integrations.actions[..] else { + panic!("integration section should request endpoint status"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::IntegrationList(_) + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::IntegrationList { + integrations: vec![ + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Codex, + label: "codex".into(), + command: "codex".into(), + available: true, + state: crate::api::schema::IntegrationState::Outdated, + }, + crate::api::schema::IntegrationInfo { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude".into(), + command: "claude".into(), + available: false, + state: crate::api::schema::IntegrationState::NotInstalled, + }, + ], + }), + ) + .0 + ); + let frame = state.compose(106, 30).expect("loaded integrations"); + 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("update available")); + assert!(text.contains("not found")); + assert!(!text.contains("pane labels")); + + let popup = state.hits.settings_popup; + let blank_click = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: popup.right().saturating_sub(2), + row: popup.y + 3, + modifiers: KeyModifiers::empty(), + })]); + assert!(!blank_click.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + + let install = state.handle_input_bytes(b"\r"); + assert_eq!(install.actions.len(), 1); + assert!(matches!( + &install.actions[0], + ClientShellAction::Endpoint { request, .. } + if matches!( + request.method, + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Codex + } + ) + ) + )); + let escape = state.handle_input_bytes(b"\x1b"); + assert!(!escape.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(_)) + )); + let install_request_id = match &install.actions[0] { + ClientShellAction::Endpoint { request, .. } => request.id.clone(), + _ => unreachable!("integration install action"), + }; + let (repaint, refresh_actions) = state.handle_endpoint_result( + "boot-1", + &install_request_id, + Ok(crate::api::schema::ResponseResult::IntegrationInstall { + target: crate::api::schema::IntegrationTarget::Codex, + details: crate::api::schema::IntegrationInstallResult { + messages: vec!["installed codex".into()], + }, + }), + ); + assert!(repaint); + assert!(matches!( + refresh_actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(request.method, crate::api::schema::Method::IntegrationList(_)) + )); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + loading_integrations: true, + installing_integrations: false, + ref integration_messages, + .. + })) if integration_messages == &["installed codex"] + )); +} diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs new file mode 100644 index 00000000..a2b5c65f --- /dev/null +++ b/src/client/shell/worktree_overlays.rs @@ -0,0 +1,410 @@ +use super::*; + +pub(super) fn render_worktree_create_overlay( + b: &mut Buffer, + create: &ClientWorktreeCreateOverlay, + p: &Palette, +) -> Option { + let popup = popup(b.area, 68, 12)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "new worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + " branch", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + let input = Rect::new(inner.x, inner.y + 3, inner.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width, + &format!(" {}", create.branch), + Style::default().fg(p.text).bg(p.surface0), + ); + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " checkout", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 6, + inner.width, + &format!(" {}", create.checkout_path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + if create.creating { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + " creating…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = create.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[20, 12], 2, 9); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ create and open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: (!create.creating).then(|| crate::protocol::CursorState { + x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_open_overlay( + b: &mut Buffer, + open: &ClientWorktreeOpenOverlay, + p: &Palette, +) -> Option { + let popup_height = (open.entries.len().saturating_mul(2) + 7).clamp(12, 26) as u16; + let popup = popup(b.area, 96, popup_height)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "open worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let search = Rect::new(inner.x, inner.y + 1, inner.width, 1); + let filtered = open.filtered_indices(); + put_text( + b, + search.x, + search.y, + search.width, + &if open.search_focused || !open.query.is_empty() { + format!(" / {}", open.query) + } else { + " / filter worktrees".to_owned() + }, + Style::default() + .fg(if open.search_focused { + p.text + } else { + p.overlay0 + }) + .bg(p.panel_bg), + ); + let count = if filtered.len() == open.entries.len() { + format!("{} checkouts", open.entries.len()) + } else { + format!("{}/{} checkouts", filtered.len(), open.entries.len()) + }; + put_right_text( + b, + search, + search.y, + &count, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(6), + ); + let visible_count = (body.height / 2).max(1) as usize; + let selected_position = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let start = selected_position + .saturating_sub(visible_count.saturating_sub(1)) + .min(filtered.len().saturating_sub(visible_count)); + let mut row_hits = Vec::new(); + for (visible, entry_index) in filtered + .iter() + .copied() + .skip(start) + .take(visible_count) + .enumerate() + { + let entry = &open.entries[entry_index]; + let rect = Rect::new(body.x, body.y + visible as u16 * 2, body.width, 2); + row_hits.push((rect, entry_index)); + let selected = entry_index == open.selected; + let style = if selected { + Style::default().fg(contrast(p)).bg(p.accent) + } else { + Style::default().fg(p.text).bg(p.panel_bg) + }; + b.set_style(rect, style); + put_text( + b, + rect.x, + rect.y, + rect.width, + &format!(" {}", entry.label), + style.add_modifier(Modifier::BOLD), + ); + let status = entry.status_label(); + if !status.is_empty() { + put_right_text(b, rect, rect.y, status, style); + } + put_text( + b, + rect.x, + rect.y + 1, + rect.width, + &format!(" {}", entry.path), + if selected { + style + } else { + Style::default().fg(p.overlay0).bg(p.panel_bg) + }, + ); + } + if filtered.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " no matching worktrees", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + if open.opening { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + " opening…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = open.error.as_deref() { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[10, 12], 2, inner.height.saturating_sub(1)); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: search, + worktree_rows: row_hits, + cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState { + x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1), + y: search.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_remove_overlay( + b: &mut Buffer, + remove: &ClientWorktreeRemoveOverlay, + p: &Palette, +) -> Option { + let popup = popup(b.area, 72, 10)?; + let inner = panel(b, popup, p.red, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + " delete worktree checkout?", + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 1, + inner.width, + " This removes the checkout folder:", + Style::default().fg(p.text).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &format!(" {}", remove.path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 3, + inner.width, + " The branch is not deleted. The Herdr workspace will close.", + Style::default().fg(p.text).bg(p.panel_bg), + ); + if remove.force_confirmation { + put_text( + b, + inner.x, + inner.y + 4, + inner.width, + " Dirty or untracked files will be permanently deleted.", + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + if remove.removing { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " removing…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = remove.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[18, 12], 2, 7); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + if remove.force_confirmation { + " ↵ delete anyway " + } else { + " ↵ remove " + }, + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs new file mode 100644 index 00000000..792cd248 --- /dev/null +++ b/src/client/shell/worktrees.rs @@ -0,0 +1,545 @@ +use super::*; + +impl ClientShellState { + fn endpoint_worktree_directory(&self) -> Option { + self.snapshot + .as_deref() + .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + } + + pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { + match self.overlay.as_mut() { + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } + create.branch.push_str(text); + self.sync_worktree_create_path(); + true + } + Some(ClientShellOverlay::WorktreeOpen(open)) + if open.search_focused && !open.opening => + { + open.query.push_str(text); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + true + } + _ => false, + } + } + + pub(super) fn route_worktree_overlay_key( + &mut self, + key: &crate::input::TerminalKey, + outcome: &mut ClientShellInput, + ) -> bool { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + let creating = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + )) + ); + match code { + KeyCode::Esc if !creating => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_create(outcome), + KeyCode::Backspace if !creating => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = + self.overlay.as_mut() + { + if create.replace_on_type { + create.branch.clear(); + create.replace_on_type = false; + } else { + create.branch.pop(); + } + } + self.sync_worktree_create_path(); + outcome.repaint = true; + } + KeyCode::Char(character) + if !creating + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + let opening = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + )) + ); + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + search_focused: true, + .. + } + )) + ); + match code { + KeyCode::Esc if !opening => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_open(outcome), + KeyCode::Up if !opening => { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + KeyCode::Down if !opening => { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + KeyCode::Char('/') if !opening && !search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + } + outcome.repaint = true; + } + KeyCode::Backspace if !opening && search_focused => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.query.pop(); + if let Some(first) = open.filtered_indices().first().copied() { + open.selected = first; + } + } + outcome.repaint = true; + } + KeyCode::Char(character) + if !opening + && search_focused + && modifiers + .difference(crossterm::event::KeyModifiers::SHIFT) + .is_empty() => + { + let text = key + .generated_text + .clone() + .unwrap_or_else(|| character.to_string()); + self.insert_worktree_overlay_text(&text); + outcome.repaint = true; + } + _ => {} + } + true + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + let removing = matches!( + self.overlay, + Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + )) + ); + match code { + KeyCode::Esc if !removing => { + self.overlay = None; + outcome.repaint = true; + } + KeyCode::Enter => self.submit_worktree_remove(outcome), + _ => {} + } + true + } + _ => false, + } + } + + pub(super) fn begin_worktree_action( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) { + let Some(workspace_id) = self.workspace_action_id() else { + return; + }; + self.begin_worktree_action_for(action, workspace_id, outcome); + } + + pub(super) fn begin_worktree_action_for( + &mut self, + action: crate::input::KeybindAction, + workspace_id: String, + outcome: &mut ClientShellInput, + ) { + use crate::api::schema::{Method, WorktreeListParams}; + use crate::input::KeybindAction; + + let workspace = self.snapshot.as_deref().and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + }); + let linked = workspace + .and_then(|workspace| workspace.worktree.as_ref()) + .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(), + ); + outcome.repaint = true; + return; + } + KeybindAction::NewWorktree => PendingEndpointKind::PrepareWorktreeCreate { + workspace_id: workspace_id.clone(), + }, + KeybindAction::OpenWorktree => PendingEndpointKind::PrepareWorktreeOpen { + workspace_id: workspace_id.clone(), + }, + KeybindAction::RemoveWorktree if !linked => { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + outcome.repaint = true; + return; + } + KeybindAction::RemoveWorktree => PendingEndpointKind::PrepareWorktreeRemove { + workspace_id: workspace_id.clone(), + }, + _ => return, + }; + self.push_endpoint_method_with_kind( + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + kind, + outcome, + ); + } + + pub(super) fn sync_worktree_create_path(&mut self) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + create.checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &create.repo_name, + &create.branch, + ) + .display() + .to_string(); + create.error = None; + } + + pub(super) fn submit_worktree_create(&mut self, outcome: &mut ClientShellInput) { + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return; + }; + let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { + return; + }; + if create.creating { + return; + } + let branch = create.branch.trim().to_owned(); + if branch.is_empty() { + create.error = Some("branch is required".to_owned()); + outcome.repaint = true; + return; + } + create.branch = branch.clone(); + create.replace_on_type = false; + create.checkout_path = + crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) + .display() + .to_string(); + create.creating = true; + create.error = None; + let workspace_id = create.source_workspace_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + branch: Some(branch), + base: Some("HEAD".to_owned()), + path: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeCreate, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn move_worktree_open_selection(&mut self, delta: isize) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + let filtered = open.filtered_indices(); + if filtered.is_empty() { + open.selected = 0; + return; + } + let current = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let next = (current as isize + delta).clamp(0, filtered.len() as isize - 1) as usize; + open.selected = filtered[next]; + } + + pub(super) fn submit_worktree_open(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else { + return; + }; + if open.opening { + return; + } + let Some(index) = open.selected_entry_index() else { + return; + }; + let Some(entry) = open.entries.get(index) else { + return; + }; + let workspace_id = open.source_workspace_id.clone(); + let path = entry.path.clone(); + open.selected = index; + open.opening = true; + open.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams { + workspace_id: Some(workspace_id), + cwd: None, + path: Some(path), + branch: None, + label: None, + focus: true, + trust_repository: false, + }), + PendingEndpointKind::WorktreeOpen, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn submit_worktree_remove(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() else { + return; + }; + if remove.removing { + return; + } + let workspace_id = remove.workspace_id.clone(); + let forced = remove.force_confirmation; + remove.removing = true; + remove.error = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams { + workspace_id, + force: forced, + trust_repository: false, + }), + PendingEndpointKind::WorktreeRemove { forced }, + outcome, + ); + outcome.repaint = true; + } + + pub(super) fn handle_worktree_endpoint_result( + &mut self, + kind: PendingEndpointKind, + result: Result, + ) -> bool { + use crate::api::schema::ResponseResult; + + match (kind, result) { + ( + PendingEndpointKind::PrepareWorktreeCreate { workspace_id }, + Ok(ResponseResult::WorktreeList { source, .. }), + ) => { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + let branch = crate::worktree::generated_branch_slug(seed); + let Some(worktree_directory) = self.endpoint_worktree_directory() else { + return false; + }; + let checkout_path = crate::worktree::default_checkout_path( + &worktree_directory, + &source.repo_name, + &branch, + ) + .display() + .to_string(); + self.overlay = Some(ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { + source_workspace_id: workspace_id, + repo_name: source.repo_name, + branch, + checkout_path, + replace_on_type: true, + error: None, + creating: false, + }, + )); + true + } + ( + PendingEndpointKind::PrepareWorktreeOpen { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let entries = worktrees + .into_iter() + .filter(|entry| !entry.is_bare && !entry.is_prunable) + .map(|entry| { + let label = entry.branch.clone().unwrap_or_else(|| entry.label.clone()); + ClientWorktreeOpenEntry { + path: entry.path, + branch: entry.branch, + is_linked_worktree: entry.is_linked_worktree, + is_detached: entry.is_detached, + open_workspace_id: entry.open_workspace_id, + label, + } + }) + .collect::>(); + if entries.is_empty() { + self.endpoint_error = Some("No Git worktrees found for this repo.".to_owned()); + } else { + self.overlay = Some(ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { + source_workspace_id: workspace_id, + entries, + selected: 0, + query: String::new(), + search_focused: false, + error: None, + opening: false, + }, + )); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeRemove { workspace_id }, + Ok(ResponseResult::WorktreeList { worktrees, .. }), + ) => { + let path = worktrees + .into_iter() + .find(|entry| entry.open_workspace_id.as_deref() == Some(&workspace_id)) + .map(|entry| entry.path); + if let Some(path) = path { + self.overlay = Some(ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { + workspace_id, + path, + error: None, + removing: false, + force_confirmation: false, + }, + )); + } else { + self.endpoint_error = + Some("This workspace is not a Herdr-managed worktree checkout.".to_owned()); + } + true + } + (PendingEndpointKind::WorktreeCreate, Ok(ResponseResult::WorktreeCreated { .. })) + | (PendingEndpointKind::WorktreeOpen, Ok(ResponseResult::WorktreeOpened { .. })) + | ( + PendingEndpointKind::WorktreeRemove { .. }, + Ok(ResponseResult::WorktreeRemoved { .. }), + ) => { + self.overlay = None; + true + } + (PendingEndpointKind::WorktreeCreate, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() { + create.creating = false; + create.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeOpen, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() { + open.opening = false; + open.error = Some(error.message); + } + true + } + (PendingEndpointKind::WorktreeRemove { forced: false }, Err(error)) + if error.code.as_deref() == Some("dirty_worktree_requires_force") => + { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.force_confirmation = true; + remove.error = None; + } + true + } + (PendingEndpointKind::WorktreeRemove { .. }, Err(error)) => { + if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() { + remove.removing = false; + remove.error = Some(error.message); + } + true + } + ( + PendingEndpointKind::PrepareWorktreeCreate { .. } + | PendingEndpointKind::PrepareWorktreeOpen { .. } + | PendingEndpointKind::PrepareWorktreeRemove { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + (_, Ok(_)) => { + self.endpoint_error = + Some("endpoint returned an unexpected worktree result".to_owned()); + true + } + ( + PendingEndpointKind::Generic + | PendingEndpointKind::ProductAnnouncementDismiss { .. } + | PendingEndpointKind::ReleaseNotesDismiss + | PendingEndpointKind::PopupCommand + | PendingEndpointKind::ReloadConfig + | PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall + | PendingEndpointKind::SelectionCopy { .. } + | PendingEndpointKind::PaneScroll { .. } + | PendingEndpointKind::WordSelection { .. } + | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::CopyMotion { .. } + | PendingEndpointKind::CopySearch { .. }, + Err(error), + ) => { + self.endpoint_error = Some(error.message); + true + } + } + } +} diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs new file mode 100644 index 00000000..d1f305ab --- /dev/null +++ b/src/client/terminal_geometry.rs @@ -0,0 +1,238 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(any(unix, test))] +use tracing::debug; + +use super::ClientLoopEvent; + +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Average cell size derived from a terminal ioctl pixel extent. +/// +/// The extent need not divide evenly by the grid: terminals may include padding, +/// and pixel mouse coordinates retain the raw extent for proportional mapping. +pub(super) fn ioctl_cell_size( + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, +) -> Option<(u32, u32)> { + if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 { + return None; + } + Some(( + (width_px / u32::from(columns)).max(1), + (height_px / u32::from(rows)).max(1), + )) +} + +fn ioctl_terminal_geometry() -> Option<(u16, u16, u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; + let (cell_width_px, cell_height_px) = ioctl_cell_size( + size.columns, + size.rows, + u32::from(size.width), + u32::from(size.height), + )?; + Some((size.columns, size.rows, cell_width_px, cell_height_px)) +} + +pub(super) fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { + unpack_cell_size(reported) + .or(last.filter(|(width, height)| *width > 0 && *height > 0)) + .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +pub(super) fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { + (u64::from(width_px) << 32) | u64::from(height_px) +} + +fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { + let width_px = (packed >> 32) as u32; + let height_px = (packed & u64::from(u32::MAX)) as u32; + (width_px > 0 && height_px > 0).then_some((width_px, height_px)) +} + +fn current_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + last_cell_size: Option<(u32, u32)>, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + if let Some((cols, rows, cell_width_px, cell_height_px)) = ioctl_terminal_geometry() { + return (cols, rows, cell_width_px, cell_height_px, true); + } + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !pixel_geometry_fallback { + return (cols, rows, 0, 0, false); + } + let (cell_width_px, cell_height_px) = + cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size); + (cols, rows, cell_width_px, cell_height_px, false) +} + +/// Reads terminal geometry before the handshake. Pixel input and direct graphics +/// are eligible only when one ioctl supplied a coherent exact geometry snapshot. +pub(super) fn initial_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + match ioctl_terminal_geometry() { + Some((cols, rows, width, height)) => (cols, rows, width, height, true), + None => { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if pixel_geometry_fallback { + ( + cols, + rows, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + false, + ) + } else { + (cols, rows, 0, 0, false) + } + } + } +} + +pub(super) fn resize_report_required( + signalled: bool, + new_size: (u16, u16, u32, u32, bool), + last_size: (u16, u16, u32, u32, bool), +) -> bool { + signalled || new_size != last_size +} + +/// Watches the terminal size and sends resize events when it changes. +/// +/// The baseline cell size must match what the handshake sent to the server: +/// reading a fresh one here would race the host cell size reply and could +/// swallow the first change. +#[allow(clippy::too_many_arguments)] // The arguments are one immutable launch snapshot, not shared state. +pub(super) fn resize_poll_loop( + resize_tx: tokio::sync::mpsc::Sender, + initial_cols: u16, + initial_rows: u16, + initial_cell_width: u32, + initial_cell_height: u32, + initial_pixel_geometry_exact: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + should_quit: &Arc, +) { + crate::platform::watch_terminal_resize_signal(); + let mut last_size = ( + initial_cols, + initial_rows, + initial_cell_width, + initial_cell_height, + initial_pixel_geometry_exact, + ); + while !should_quit.load(Ordering::Acquire) { + std::thread::sleep(Duration::from_millis(100)); + let signalled = crate::platform::take_terminal_resize_signal(); + let new_size = current_terminal_geometry( + pixel_geometry_enabled, + pixel_geometry_fallback, + reported_cell_size, + Some((last_size.2, last_size.3)), + ); + if resize_report_required(signalled, new_size, last_size) { + last_size = new_size; + if resize_tx + .blocking_send(ClientLoopEvent::Resize( + new_size.0, new_size.1, new_size.2, new_size.3, new_size.4, + )) + .is_err() + { + break; + } + } + } +} + +#[cfg(any(not(windows), test))] +pub(super) fn query_host_terminal_appearance() { + let _ = write_host_terminal_appearance_query(io::stdout()); +} + +#[cfg(any(not(windows), test))] +pub(super) fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; + writer.flush() +} + +pub(super) fn query_host_terminal_theme() { + let _ = write_host_terminal_theme_query(io::stdout()); +} + +pub(super) fn should_query_host_terminal_theme() -> bool { + !cfg!(windows) +} + +pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { + let query = crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ); + writer.write_all(query.as_bytes())?; + writer.flush() +} + +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +pub(super) fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +pub(super) fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +pub(super) fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_terminal_geometry().is_none() +} + +pub(super) fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(HOST_CELL_SIZE_QUERY)?; + writer.flush() +} + +#[cfg(any(unix, test))] +pub(super) fn store_reported_cell_size( + reported_cell_size: &AtomicU64, + width_px: u32, + height_px: u32, +) { + let packed = pack_cell_size(width_px, height_px); + if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { + debug!(width_px, height_px, "host terminal reported cell size"); + } +} + +#[cfg(any(unix, test))] +pub(super) fn reported_cell_size_from_events( + events: &[crate::raw_input::RawInputEvent], +) -> Option<(u32, u32)> { + events.iter().rev().find_map(|event| match event { + crate::raw_input::RawInputEvent::HostCellSizeReport { + width_px, + height_px, + } => Some((*width_px, *height_px)), + _ => None, + }) +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs new file mode 100644 index 00000000..7d7bcc8f --- /dev/null +++ b/src/client/terminal_sessions.rs @@ -0,0 +1,265 @@ +use std::io::{self, BufRead, Write as _}; + +use base64::Engine; +use interprocess::local_socket::traits::Stream as _; +use interprocess::TryClone as _; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage, + MAX_GRAPHICS_FRAME_SIZE, +}; +use crate::server::socket_paths::client_socket_path; + +use super::{do_handshake, init_logging, write_to_server, ClientError}; + +/// Runs a read-only terminal session observer and prints one JSON envelope per frame. +pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { + let mut stream = + connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; + write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; + write_terminal_session_output(stream) +} + +/// Runs a writable terminal session controller. +pub fn run_terminal_session_control( + target: String, + takeover: bool, + cols: u16, + rows: u16, +) -> io::Result<()> { + let mut stream = connect_terminal_session_stream( + target.clone(), + cols, + rows, + "controlling terminal session", + )?; + write_to_server( + &mut stream, + &ClientMessage::ControlTerminal { target, takeover }, + )?; + + let mut write_stream = stream.try_clone()?; + let _input_thread = std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { + break; + }; + if line.trim().is_empty() { + continue; + } + match terminal_control_command_from_json(&line) { + Ok(message) => { + let release = matches!(message, ClientMessage::Detach); + if write_to_server(&mut write_stream, &message).is_err() { + return; + } + if release { + return; + } + } + Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), + } + } + let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); + }); + + write_terminal_session_output(stream) +} + +fn connect_terminal_session_stream( + target: String, + cols: u16, + rows: u16, + log_message: &'static str, +) -> io::Result { + init_logging(); + + let socket_path = client_socket_path(); + crate::logging::startup("client"); + info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); + + let mut stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => stream, + Err(err) => { + eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); + std::process::exit(1); + } + }; + + match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + Ok(RenderEncoding::TerminalAnsi) => {} + Ok(encoding) => { + eprintln!( + "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" + ); + std::process::exit(1); + } + Err(err) => { + eprintln!("herdr: {err}"); + std::process::exit(1); + } + } + + stream.set_nonblocking(false)?; + Ok(stream) +} + +fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + loop { + match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { + Ok(ServerMessage::Terminal(frame)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); + let line = serde_json::json!({ + "type": "terminal.frame", + "seq": frame.seq, + "encoding": "ansi", + "width": frame.width, + "height": frame.height, + "full": frame.full, + "bytes": encoded, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(ServerMessage::ServerShutdown { reason }) => { + let line = serde_json::json!({ + "type": "terminal.closed", + "reason": reason, + }); + serde_json::to_writer(&mut stdout, &line)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + return Ok(()); + } + Ok(ServerMessage::Graphics { .. }) => {} + Ok(_) => {} + Err(protocol::FramingError::UnexpectedEof) => return Ok(()), + Err(err) => return Err(io::Error::other(err.to_string())), + } + } +} + +#[derive(serde::Deserialize)] +#[serde(tag = "type")] +enum TerminalControlCommand { + #[serde(rename = "terminal.input")] + Input { + text: Option, + bytes: Option, + }, + #[serde(rename = "terminal.resize")] + Resize { + cols: u16, + rows: u16, + #[serde(default)] + cell_width_px: u32, + #[serde(default)] + cell_height_px: u32, + }, + #[serde(rename = "terminal.scroll")] + Scroll { + direction: TerminalControlScrollDirection, + lines: u16, + #[serde(default)] + source: TerminalControlScrollSource, + #[serde(default)] + column: Option, + #[serde(default)] + row: Option, + #[serde(default)] + modifiers: u8, + }, + #[serde(rename = "terminal.release")] + Release {}, +} + +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlScrollSource { + #[default] + Wheel, + PageKey, +} + +pub(super) fn terminal_control_command_from_json(raw: &str) -> Result { + let command = serde_json::from_str::(raw) + .map_err(|err| format!("invalid json command: {err}"))?; + match command { + TerminalControlCommand::Input { text, bytes } => { + let data = match (text, bytes) { + (Some(_), Some(_)) => { + return Err("terminal.input accepts text or bytes, not both".into()) + } + (Some(text), None) => text.into_bytes(), + (None, Some(bytes)) => base64::engine::general_purpose::STANDARD + .decode(bytes) + .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, + (None, None) => Vec::new(), + }; + Ok(ClientMessage::Input { data }) + } + TerminalControlCommand::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + } => { + if cols == 0 || rows == 0 { + return Err("terminal.resize cols and rows must be greater than 0".into()); + } + Ok(ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: false, + }) + } + TerminalControlCommand::Scroll { + direction, + lines, + source, + column, + row, + modifiers, + } => { + if lines == 0 { + return Err("terminal.scroll lines must be greater than 0".into()); + } + let direction = match direction { + TerminalControlScrollDirection::Up => AttachScrollDirection::Up, + TerminalControlScrollDirection::Down => AttachScrollDirection::Down, + }; + let source = match source { + TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, + TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { + input: match direction { + AttachScrollDirection::Up => b"\x1b[5~".to_vec(), + AttachScrollDirection::Down => b"\x1b[6~".to_vec(), + }, + }, + }; + Ok(ClientMessage::AttachScroll { + source, + direction, + lines, + column, + row, + modifiers, + }) + } + TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), + } +} diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs new file mode 100644 index 00000000..9c035346 --- /dev/null +++ b/src/client/terminal_setup.rs @@ -0,0 +1,445 @@ +//! Terminal setup and restoration for the rendered client. + +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, +}; +#[cfg(not(windows))] +use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; +use crossterm::execute; +use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; + +use super::frame_output::clear_received_kitty_graphics; +use super::terminal_geometry::should_query_host_terminal_theme; + +// --------------------------------------------------------------------------- +// Terminal setup / restore +// --------------------------------------------------------------------------- + +/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). +/// +/// Returns a guard that restores the terminal when dropped. +pub(super) fn setup_terminal(mouse_capture: bool) -> io::Result { + setup_terminal_with_capabilities(true, mouse_capture) +} + +/// Sets up a direct attach terminal. +/// +/// Direct attach forwards stdin to the attached PTY. When configured, mouse +/// capture lets wheel events drive the attached viewport or reach child +/// programs that requested mouse input. +pub(super) fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result { + setup_terminal_with_capabilities(false, mouse_capture) +} + +pub(super) fn setup_terminal_with_capabilities( + enable_client_protocols: bool, + mouse_capture: bool, +) -> io::Result { + ratatui::init(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let host_color_scheme_reports = + should_enable_host_color_scheme_reports(enable_client_protocols); + + #[cfg(windows)] + let windows_ssh_session = is_ssh_session(); + #[cfg(windows)] + let mut windows_virtual_terminal_input = + if windows_vti_input_backend_enabled() && windows_ssh_session { + enable_windows_virtual_terminal_input() + } else { + WindowsVirtualTerminalInputSetup::default() + }; + + if enable_client_protocols { + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; + if host_color_scheme_reports { + write_host_color_scheme_report_mode(&mut io::stdout(), true)?; + } + push_keyboard_enhancement_flags()?; + } else { + if should_query_host_terminal_theme() { + write_host_color_scheme_report_mode(&mut io::stdout(), false)?; + } + set_mouse_capture(mouse_capture, false)?; + execute!(io::stdout(), EnableBracketedPaste)?; + } + + #[cfg(windows)] + if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { + windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); + } + + #[cfg(windows)] + if enable_client_protocols + && windows_vti_input_backend_enabled() + && windows_virtual_terminal_input.active + && windows_win32_input_mode_enabled() + { + if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { + if let Some(mode) = windows_virtual_terminal_input.restore_mode { + restore_windows_input_mode_value(mode); + } + return Err(err); + } + } + + let modify_other_keys_mode = enable_client_protocols + .then(crate::input::host_modify_other_keys_mode) + .flatten(); + if let Some(mode) = modify_other_keys_mode { + io::stdout().write_all(mode.set_sequence())?; + io::stdout().flush()?; + } + + execute!(io::stdout(), DisableLineWrap)?; + + Ok(TerminalGuard { + reset_keyboard_enhancements: enable_client_protocols, + reset_modify_other_keys: modify_other_keys_mode.is_some(), + reset_host_color_scheme_reports: host_color_scheme_reports, + restore_claimed: Arc::new(AtomicBool::new(false)), + restored: false, + #[cfg(windows)] + restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, + }) +} + +pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { + enable_client_protocols && should_query_host_terminal_theme() +} + +/// Guard that restores the terminal when dropped. +pub(super) struct TerminalGuard { + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + restore_claimed: Arc, + restored: bool, + #[cfg(windows)] + restore_windows_input_mode: Option, +} + +pub(super) fn write_host_color_scheme_report_mode( + writer: &mut impl io::Write, + enabled: bool, +) -> io::Result<()> { + let sequence = if enabled { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE + } else { + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE + }; + writer.write_all(sequence.as_bytes())?; + writer.flush() +} + +pub(super) fn write_terminal_restore_postlude( + writer: &mut impl io::Write, + reset_host_color_scheme_reports: bool, +) -> io::Result<()> { + if reset_host_color_scheme_reports { + writer.write_all( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + )?; + } + // Restore a visible cursor and reset DECSCUSR back to the terminal default. + writer.write_all(b"\x1b[?25h\x1b[0 q")?; + writer.flush() +} + +pub(super) fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { + match mode { + crate::config::HostCursorModeConfig::Auto => { + crate::platform::should_draw_host_cursor_by_default() + } + crate::config::HostCursorModeConfig::Native => false, + crate::config::HostCursorModeConfig::Drawn => true, + } +} + +#[cfg(windows)] +#[derive(Default)] +pub(super) struct WindowsVirtualTerminalInputSetup { + active: bool, + restore_mode: Option, +} + +#[cfg(windows)] +pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, + STD_INPUT_HANDLE, + }; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + tracing::warn!("failed to get Windows console input handle for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + tracing::warn!("failed to read Windows console input mode for VT input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let desired = windows_virtual_terminal_input_mode(mode); + if desired == mode { + return WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: None, + }; + } + + if unsafe { SetConsoleMode(handle, desired) } == 0 { + tracing::warn!("failed to enable Windows virtual terminal input"); + return WindowsVirtualTerminalInputSetup::default(); + } + + let mut applied = 0; + if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { + tracing::warn!("failed to verify Windows virtual terminal input mode"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { + tracing::warn!("Windows virtual terminal input bit did not stick"); + let _ = unsafe { SetConsoleMode(handle, mode) }; + return WindowsVirtualTerminalInputSetup::default(); + } + + WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: Some(mode), + } +} + +pub(super) fn is_ssh_session() -> bool { + std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() +} + +#[cfg(windows)] +pub(super) fn windows_vti_input_backend_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_BACKEND") + .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) + .unwrap_or(true) +} + +#[cfg(any(windows, test))] +pub(super) fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { + mode | 0x0200 +} + +#[cfg(windows)] +fn restore_windows_input_mode_value(mode: u32) { + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; + + let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return; + } + if unsafe { SetConsoleMode(handle, mode) } == 0 { + tracing::warn!("failed to restore Windows console input mode"); + } +} + +pub(super) fn effective_mouse_capture( + server_enabled: bool, + direct_attach_preference: bool, +) -> bool { + server_enabled || direct_attach_preference +} + +pub(super) fn effective_sgr_pixel_mouse( + enabled: bool, + requested: bool, + exact_geometry: bool, +) -> bool { + enabled && requested && exact_geometry +} + +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( + &mut io::stdout(), + enabled, + sgr_pixels, + ); + } + if enabled { + execute!(io::stdout(), EnableMouseCapture)?; + if sgr_pixels { + io::stdout().write_all(b"\x1b[?1016h")?; + io::stdout().flush()?; + } + Ok(()) + } 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), + } + } +} + +fn restore_terminal_state_once( + restore_claimed: &AtomicBool, + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option, +) -> io::Result<()> { + if restore_claimed.swap(true, Ordering::AcqRel) { + return Ok(()); + } + restore_terminal_state( + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ) +} + +fn restore_terminal_state( + reset_keyboard_enhancements: bool, + reset_modify_other_keys: bool, + reset_host_color_scheme_reports: bool, + #[cfg(windows)] restore_windows_input_mode: Option, +) -> io::Result<()> { + let _ = clear_received_kitty_graphics(&mut io::stdout()); + + // Reset modifyOtherKeys if we enabled it. + if reset_modify_other_keys { + let _ = io::stdout().write_all(b"\x1b[>4;0m"); + let _ = io::stdout().flush(); + } + + if reset_keyboard_enhancements { + let _ = pop_keyboard_enhancement_flags(); + } + + let _ = execute!( + io::stdout(), + EnableLineWrap, + DisableFocusChange, + DisableBracketedPaste + ); + let _ = set_mouse_capture(false, false); + #[cfg(windows)] + if let Some(mode) = restore_windows_input_mode { + restore_windows_input_mode_value(mode); + } + + let restore_result = ratatui::try_restore(); + let postlude_result = + write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); + + #[cfg(windows)] + if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { + let _ = disable_windows_win32_input_mode(&mut io::stdout()); + } + + restore_result.and(postlude_result) +} + +#[cfg(not(windows))] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + execute!( + io::stdout(), + PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) + ) +} + +#[cfg(windows)] +fn push_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(not(windows))] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + execute!(io::stdout(), PopKeyboardEnhancementFlags) +} + +#[cfg(windows)] +fn pop_keyboard_enhancement_flags() -> io::Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn windows_win32_input_mode_enabled() -> bool { + std::env::var("HERDR_WINDOWS_INPUT_PROBE") + .map(|probe| probe.eq_ignore_ascii_case("win32")) + .unwrap_or(true) +} + +#[cfg(windows)] +fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001h")?; + writer.flush() +} + +#[cfg(windows)] +fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { + writer.write_all(b"\x1b[?9001l")?; + writer.flush() +} + +impl TerminalGuard { + /// Captures the restoration state for use by the process panic hook. + pub(super) fn panic_restore(&self) -> impl Fn() + Send + Sync + 'static { + let restore_claimed = self.restore_claimed.clone(); + let reset_keyboard_enhancements = self.reset_keyboard_enhancements; + let reset_modify_other_keys = self.reset_modify_other_keys; + let reset_host_color_scheme_reports = self.reset_host_color_scheme_reports; + #[cfg(windows)] + let restore_windows_input_mode = self.restore_windows_input_mode; + move || { + let _ = restore_terminal_state_once( + &restore_claimed, + reset_keyboard_enhancements, + reset_modify_other_keys, + reset_host_color_scheme_reports, + #[cfg(windows)] + restore_windows_input_mode, + ); + } + } + + pub(super) fn restore(mut self) -> io::Result<()> { + self.restored = true; + restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if !self.restored { + let _ = restore_terminal_state_once( + &self.restore_claimed, + self.reset_keyboard_enhancements, + self.reset_modify_other_keys, + self.reset_host_color_scheme_reports, + #[cfg(windows)] + self.restore_windows_input_mode, + ); + } + } +} diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs new file mode 100644 index 00000000..d8f71a84 --- /dev/null +++ b/src/client/tests/mod.rs @@ -0,0 +1,823 @@ +use super::*; +use std::ffi::OsString; +use std::sync::{Mutex, OnceLock}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[test] +fn resize_signal_reports_even_when_polled_size_is_unchanged() { + let size = (120, 40, 8, 16, true); + assert!(resize_report_required(true, size, size)); + assert!(!resize_report_required(false, size, size)); + assert!(resize_report_required(false, (120, 41, 8, 16, true), size)); + assert!(resize_report_required(false, (120, 40, 9, 18, true), size)); + assert!(resize_report_required(false, (120, 40, 8, 16, false), size)); +} + +#[test] +fn direct_graphics_profile_is_narrow_and_transport_safe() { + for (program, term, kitty, expected) in [ + ("ghostty", "", false, true), + ("WezTerm", "", false, true), + ("", "xterm-kitty", false, true), + ("", "xterm-256color", true, true), + ("", "xterm-256color", false, false), + ] { + assert_eq!( + direct_graphics_profile_values(program, term, kitty, false, true), + expected + ); + } + assert!(!direct_graphics_profile_values( + "ghostty", "", false, true, true + )); + assert!(!direct_graphics_profile_values( + "ghostty", "", false, false, false + )); +} + +fn restore_env_var(key: &str, value: Option) { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + restore_env_var(self.key, self.previous.clone()); + } +} + +#[test] +fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { + assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); + assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); +} + +struct EnvVarsRemovedGuard { + previous: Vec<(&'static str, Option)>, +} + +impl EnvVarsRemovedGuard { + fn new(keys: &[&'static str]) -> Self { + let previous: Vec<_> = keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(); + for key in keys { + std::env::remove_var(key); + } + Self { previous } + } +} + +impl Drop for EnvVarsRemovedGuard { + fn drop(&mut self) { + for (key, value) in self.previous.clone() { + restore_env_var(key, value); + } + } +} + +#[test] +fn remote_client_uses_extended_handshake_timeout() { + let _guard = env_lock().lock().unwrap(); + let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); + + assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); +} + +#[test] +fn host_cursor_policy_auto_uses_platform_default() { + assert_eq!( + should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), + crate::platform::should_draw_host_cursor_by_default() + ); +} + +#[test] +fn host_cursor_policy_native_and_drawn_override_auto_detection() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); + + assert!(!should_draw_host_cursor( + crate::config::HostCursorModeConfig::Native + )); + assert!(should_draw_host_cursor( + crate::config::HostCursorModeConfig::Drawn + )); +} + +#[cfg(unix)] +#[test] +fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { + let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); + assert!(should_bridge_clipboard_image_paste( + &[0x16], + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[118;5u", + true, + Some(ctrl_v) + )); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + true, + None + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~text\x1b[201~", + true, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); + assert!(!should_bridge_clipboard_image_paste( + b"v", + true, + Some(ctrl_v) + )); +} + +struct TempImageFile { + path: std::path::PathBuf, +} + +impl TempImageFile { + fn new(extension: &str, bytes: &[u8]) -> Self { + Self::with_name_fragment("test", extension, bytes) + } + + fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", + std::process::id() + )); + std::fs::write(&path, bytes).unwrap(); + Self { path } + } +} + +impl Drop for TempImageFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { + let file = TempImageFile::new("PNG", b"image-bytes"); + let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { + let file = TempImageFile::new("jpeg", b"jpeg-bytes"); + let input = format!("'{}'\n", file.path.display()); + + let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "jpg"); + assert_eq!(image.bytes, b"jpeg-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { + let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); + let escaped_path = file.path.display().to_string().replace(' ', "\\ "); + + let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); +} + +#[cfg(unix)] +#[test] +fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { + let file = TempImageFile::new("png", b"image-bytes"); + let path = file.path.display().to_string(); + + assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); + assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); + assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); + assert!(read_image_file_from_terminal_drop( + format!("{}\nextra", file.path.display()).as_bytes(), + true + ) + .is_none()); +} + +#[test] +fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics( + &mut output, + b"\x1b[?2026htext\x1b[?2026lcursor", + b"graphics", + ) + .unwrap(); + + assert_eq!( + output, + b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" + ); +} + +#[test] +fn empty_graphics_writes_only_blit_frame() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); + + assert_eq!(output, b"text"); +} + +#[test] +fn terminal_frame_kitty_detection_matches_apc_prefix() { + assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); + assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); +} + +#[test] +fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { + let ids = kitty_graphics_image_ids( + b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", + ); + assert_eq!(ids, vec![10023, 10023]); +} + +#[test] +fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { + record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); + let mut output = Vec::new(); + clear_received_kitty_graphics(&mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("a=d,d=I,i=123")); + assert!(!text.contains("d=A")); +} + +#[test] +fn write_host_terminal_appearance_query_emits_mode_2031_query() { + let mut output = Vec::new(); + write_host_terminal_appearance_query(&mut output).unwrap(); + assert_eq!(output, b"\x1b[?996n"); +} + +#[test] +fn write_host_terminal_theme_query_emits_osc_queries() { + let mut output = Vec::new(); + write_host_terminal_theme_query(&mut output).unwrap(); + assert_eq!( + output, + crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ) + .as_bytes() + ); + assert!( + !output + .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) + .any(|window| window + == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()) + ); +} + +#[test] +fn write_host_color_scheme_report_mode_emits_mode_sequences() { + let mut output = Vec::new(); + write_host_color_scheme_report_mode(&mut output, true).unwrap(); + write_host_color_scheme_report_mode(&mut output, false).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + assert_eq!(output, expected); +} + +#[test] +fn color_scheme_change_event_requests_host_theme_query() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + + assert!(crate::raw_input::events_require_host_terminal_theme_query( + &events + )); +} + +#[test] +fn host_terminal_theme_query_is_disabled_on_windows() { + assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); +} + +#[test] +fn write_host_cell_size_query_emits_xtwinops_request() { + let mut output = Vec::new(); + write_host_cell_size_query(&mut output).unwrap(); + + assert_eq!(output, b"\x1b[16t"); +} + +#[test] +fn host_cell_size_query_is_disabled_on_windows() { + assert_eq!(should_query_host_cell_size(), !cfg!(windows)); +} + +#[test] +fn cell_size_fallback_prefers_reported_then_previous_size() { + assert_eq!(cell_size_fallback(0, None), (8, 16)); + assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); + assert_eq!( + cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), + (10, 21) + ); + assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); +} + +#[test] +fn reported_cell_size_is_taken_from_host_cell_size_events() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + assert_eq!( + super::terminal_geometry::reported_cell_size_from_events(&events), + None + ); + + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); + assert_eq!( + super::terminal_geometry::reported_cell_size_from_events(&events), + Some((9, 18)) + ); +} + +#[test] +fn color_scheme_reports_are_enabled_only_for_full_clients() { + assert_eq!( + should_enable_host_color_scheme_reports(true), + !cfg!(windows) + ); + assert!(!should_enable_host_color_scheme_reports(false)); +} + +#[test] +fn terminal_restore_postlude_restores_visible_default_cursor() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, false).unwrap(); + assert_eq!(output, b"\x1b[?25h\x1b[0 q"); +} + +#[test] +fn direct_attach_mouse_capture_combines_local_preference_with_child_demand() { + assert!(effective_mouse_capture(false, true)); + assert!(effective_mouse_capture(true, false)); + assert!(!effective_mouse_capture(false, false)); + assert!(effective_sgr_pixel_mouse(true, true, true)); + assert!(!effective_sgr_pixel_mouse(true, true, false)); +} + +#[test] +fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { + let mut output = Vec::new(); + write_terminal_restore_postlude(&mut output, true).unwrap(); + + let mut expected = Vec::new(); + expected.extend_from_slice( + crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), + ); + expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); + assert_eq!(output, expected); +} + +#[test] +fn client_error_display_connection_failed() { + let err = ClientError::ConnectionFailed(io::Error::new( + io::ErrorKind::ConnectionRefused, + "connection refused", + )); + let msg = err.to_string(); + assert!( + msg.contains("failed to connect to server"), + "should mention connection failure: {msg}" + ); + assert!( + msg.contains("herdr server"), + "should suggest starting server: {msg}" + ); +} + +#[test] +fn client_error_display_handshake_rejected() { + let err = ClientError::HandshakeRejected { + version: 1, + error: "incompatible".into(), + }; + let msg = err.to_string(); + assert!( + msg.contains("rejected handshake"), + "should mention rejection: {msg}" + ); + assert!(msg.contains("incompatible"), "should include error: {msg}"); +} + +#[test] +fn client_error_display_server_shutdown() { + let err = ClientError::ServerShutdown { + reason: Some("maintenance".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("server shut down"), + "should mention shutdown: {msg}" + ); + assert!(msg.contains("maintenance"), "should include reason: {msg}"); +} + +#[test] +fn client_error_display_server_shutdown_no_reason() { + let err = ClientError::ServerShutdown { reason: None }; + let msg = err.to_string(); + assert!( + msg.contains("server shut down"), + "should mention shutdown: {msg}" + ); +} + +#[test] +fn client_error_display_detached_default_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[ + crate::remote::REATTACH_COMMAND_ENV_VAR, + crate::session::SESSION_ENV_VAR, + ]); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr` to reattach"), + "should suggest default reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_named_session_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr session attach work` to reattach"), + "should suggest named session reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_detached_remote_reattach_hint_takes_precedence() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); + let err = ClientError::ServerShutdown { + reason: Some("detached".into()), + }; + let msg = err.to_string(); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should prefer remote reattach command: {msg}" + ); +} + +#[test] +fn client_error_display_connection_lost() { + let _guard = env_lock().lock().unwrap(); + let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); + let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); + let msg = err.to_string(); + assert!( + msg.contains("lost connection to server"), + "should mention lost connection: {msg}" + ); +} + +#[test] +fn client_error_display_remote_connection_lost_has_reattach_hint() { + let _guard = env_lock().lock().unwrap(); + let _remote_env = EnvVarGuard::set( + crate::remote::REATTACH_COMMAND_ENV_VAR, + "herdr --remote host --session work", + ); + let err = ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); + let msg = err.to_string(); + assert!( + msg.contains("lost connection to remote Herdr"), + "should mention remote connection loss: {msg}" + ); + assert!( + msg.contains("panes may still be running"), + "should explain possible persistence: {msg}" + ); + assert!( + msg.contains("Run `herdr --remote host --session work` to reattach"), + "should show remote reattach command: {msg}" + ); +} + +#[test] +fn sound_from_notify_message_maps_done() { + assert_eq!( + sound_from_notify_message("agent done"), + Some(crate::sound::Sound::Done) + ); +} + +#[test] +fn sound_from_notify_message_maps_attention() { + assert_eq!( + sound_from_notify_message("agent attention"), + Some(crate::sound::Sound::Request) + ); +} + +#[test] +fn sound_from_notify_message_rejects_unknown_payloads() { + assert_eq!(sound_from_notify_message("toast"), None); +} + +#[test] +fn reload_local_client_config_refreshes_local_client_presentation_state() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-config-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write( + &path, + "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\nmouse_capture = false\n", + ) + .unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = true; + let mut draw_host_cursor = false; + let mut remote_image_paste_key = None; + let mut mouse_capture = true; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + assert!(!mouse_capture); + let _ = std::fs::remove_file(path); +} + +#[test] +fn reload_local_client_config_keeps_ui_preferences_when_ui_is_invalid() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-client-invalid-ui-reload-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "[ui]\nmouse_capture = \"invalid\"\n").unwrap(); + let path_string = path.to_string_lossy().to_string(); + let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); + let mut sound_config = crate::config::SoundConfig::default(); + let mut redraw_on_focus_gained = false; + let mut draw_host_cursor = true; + let mut remote_image_paste_key = None; + let mut mouse_capture = false; + + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + &mut draw_host_cursor, + &mut remote_image_paste_key, + &mut mouse_capture, + ); + + assert!(!mouse_capture); + assert!(!redraw_on_focus_gained); + assert!(draw_host_cursor); + let _ = std::fs::remove_file(path); +} + +#[test] +fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::Toast, + "pi finished", + Some("workspace 1"), + &sound_config, + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + |_, _| Ok(false), + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_from_server_uses_system_notifier() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "pi finished", + Some("workspace 1"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(("pi finished".to_string(), Some("workspace 1".to_string()))) + ); +} + +#[test] +fn system_toast_notify_preserves_colon_in_title() { + let sound_config = crate::config::SoundConfig::default(); + let mut emitted = None; + + handle_notify_with_notifiers( + NotifyKind::SystemToast, + "build: failed", + Some("api workspace"), + &sound_config, + |_, _| Ok(false), + |title, body| { + emitted = Some((title.to_string(), body.map(str::to_string))); + Ok(true) + }, + ); + + assert_eq!( + emitted, + Some(( + "build: failed".to_string(), + Some("api workspace".to_string()) + )) + ); +} + +#[test] +fn decode_clipboard_payload_decodes_base64() { + assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); +} + +#[test] +fn ioctl_cell_size_accepts_fractional_terminal_geometry() { + assert_eq!(ioctl_cell_size(80, 24, 800, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 805, 480), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 800, 485), Some((10, 20))); + assert_eq!(ioctl_cell_size(80, 24, 0, 485), None); +} + +#[test] +fn decode_clipboard_payload_rejects_invalid_base64() { + assert_eq!(decode_clipboard_payload("not-base64!!!"), None); +} + +#[test] +fn terminal_control_input_command_accepts_text() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"hello"); +} + +#[test] +fn terminal_control_input_command_accepts_base64_bytes() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#).unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"\x1b[A"); +} + +#[test] +fn terminal_control_resize_command_maps_to_client_resize() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, + ) + .unwrap(); + let ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } = action + else { + panic!("expected resize command"); + }; + assert_eq!( + (cols, rows, cell_width_px, cell_height_px), + (100, 30, 8, 16) + ); + assert!(!pixel_mouse); +} + +#[test] +fn terminal_control_scroll_command_maps_to_attach_scroll() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, + ) + .unwrap(); + let ClientMessage::AttachScroll { + source, + direction, + lines, + .. + } = action + else { + panic!("expected scroll command"); + }; + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 3); +} + +#[test] +fn forward_clipboard_uses_local_clipboard_path() { + unsafe { + std::env::set_var("SSH_CONNECTION", "1 2 3 4"); + } + assert!(forward_clipboard("dGVzdA==")); + assert!(!forward_clipboard("not base64")); + unsafe { + std::env::remove_var("SSH_CONNECTION"); + } +} diff --git a/src/client/timer.rs b/src/client/timer.rs new file mode 100644 index 00000000..bd5c249a --- /dev/null +++ b/src/client/timer.rs @@ -0,0 +1,78 @@ +use std::time::{Duration, Instant}; + +pub(super) struct ClientLoopTimer { + deadline: Option, +} + +impl ClientLoopTimer { + pub(super) fn new() -> Self { + Self { deadline: None } + } + + pub(super) fn deadline(&mut self, now: Instant, delay: Duration) -> Instant { + let requested = now.checked_add(delay).unwrap_or(now); + let deadline = self + .deadline + .map_or(requested, |current| current.min(requested)); + self.deadline = Some(deadline); + deadline + } + + pub(super) fn fired(&mut self) { + self.deadline = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incoming_events_do_not_postpone_timer_deadline() { + let start = Instant::now(); + let delay = Duration::from_millis(100); + let mut timer = ClientLoopTimer::new(); + + let first_deadline = timer.deadline(start, delay); + assert_eq!( + timer.deadline(start + Duration::from_millis(25), delay), + first_deadline + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(50), delay), + first_deadline + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(75), delay), + first_deadline + ); + + timer.fired(); + assert_eq!( + timer.deadline(first_deadline, delay), + first_deadline + delay + ); + } + + #[test] + fn earlier_client_work_can_pull_the_timer_deadline_forward() { + let start = Instant::now(); + let mut timer = ClientLoopTimer::new(); + + assert_eq!( + timer.deadline(start, Duration::from_millis(100)), + start + Duration::from_millis(100) + ); + assert_eq!( + timer.deadline(start + Duration::from_millis(20), Duration::from_millis(10)), + start + Duration::from_millis(30) + ); + assert_eq!( + timer.deadline( + start + Duration::from_millis(25), + Duration::from_millis(100) + ), + start + Duration::from_millis(30) + ); + } +} diff --git a/src/config.rs b/src/config.rs index 02ad2fbc..117374ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,7 @@ mod sound; mod tab_bar; mod theme; mod window_title; +mod write; pub use self::{ io::{ @@ -38,6 +39,7 @@ pub use self::{ }; pub(crate) use self::keybinds::parse_key_combo; +pub(crate) use self::write::{update_file_at, write_edit, ConfigEdit}; pub(crate) use self::{ io::upsert_top_level_bool, tab_bar::{ @@ -49,7 +51,28 @@ pub(crate) use self::{ window_title::{sanitize_window_title_text, window_title_diagnostics}, }; +pub(crate) use self::{keybinds::CommandKeybindType, model::KeysConfig}; + pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; + +pub(crate) fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool { + if diagnostic.starts_with("config parse error:") || diagnostic.starts_with("config read error:") + { + return false; + } + diagnostic.contains("keybinding") || diagnostic.contains("keys.") +} + +pub(crate) fn config_diagnostic_summary_without_keybindings( + diagnostics: &[String], +) -> Option { + let diagnostics = diagnostics + .iter() + .filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic)) + .cloned() + .collect::>(); + config_diagnostic_summary(&diagnostics) +} pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000; pub const DEFAULT_MOUSE_SCROLL_LINES: usize = 3; pub const DEFAULT_MOBILE_WIDTH_THRESHOLD: u16 = 64; @@ -134,12 +157,6 @@ impl Config { }) } - #[cfg(test)] - pub fn live_keybinds(&self) -> Result> { - self.live_keybinds_with_diagnostics() - .map(|(live, _diagnostics)| live) - } - pub(crate) fn live_keybinds_with_diagnostics( &self, ) -> Result<(LiveKeybindConfig, Vec), Vec> { @@ -157,12 +174,21 @@ impl Config { keys: model::KeysConfigOverlay, } - toml::to_string_pretty(&KeysProfile { - keys: self.keys.local_profile(&self.keybinds()), - }) + let mut keys = self.keys.local_profile(&self.keybinds()); + keys.set_prefix(format_key_combo(self.prefix_key())); + toml::to_string_pretty(&KeysProfile { keys }) } } +pub(crate) fn keybindings_from_profile_toml(profile: &str) -> Result { + let config = toml::from_str::(profile) + .map_err(|err| format!("invalid keybinding profile: {err}"))?; + config + .live_keybinds_with_diagnostics() + .map(|(keybinds, _diagnostics)| keybinds) + .map_err(|diagnostics| diagnostics.join("; ")) +} + #[cfg(test)] mod tests { use super::*; @@ -192,6 +218,23 @@ command = "lazygit" assert!(!profile.contains("[[keys.command]]")); } + #[test] + fn local_keybindings_profile_publishes_the_effective_prefix_fallback() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+" +"#, + ) + .unwrap(); + + let profile = config.local_keybindings_profile_toml().unwrap(); + let keybinds = keybindings_from_profile_toml(&profile).unwrap(); + + assert!(profile.contains("prefix = \"ctrl+b\"")); + assert_eq!(keybinds.prefix, config.prefix_key()); + } + #[test] fn local_keybindings_profile_preserves_user_default_provenance() { let config: Config = toml::from_str( diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 6cddd3ac..bec1af66 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -169,6 +169,18 @@ pub struct ActionKeybinds { } impl ActionKeybinds { + pub(crate) fn from_labels(labels: &[String]) -> Result { + let mut bindings = Vec::new(); + for label in labels { + match parse_binding_string(label) { + Some(ParsedBinding::Single(binding)) => bindings.push(binding), + Some(ParsedBinding::Range(range)) => bindings.extend(range), + None => return Err(format!("invalid endpoint command binding: {label}")), + } + } + Ok(Self { bindings }) + } + #[cfg(test)] pub fn prefix(label: &str) -> Self { let raw = if label.starts_with("prefix+") { diff --git a/src/config/model.rs b/src/config/model.rs index c10cd733..552c19de 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -101,15 +101,6 @@ pub enum AgentPanelSortConfig { Priority, } -impl AgentPanelSortConfig { - pub fn as_str(self) -> &'static str { - match self { - Self::Spaces => "spaces", - Self::Priority => "priority", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] enum LegacyAgentPanelScopeConfig { @@ -372,7 +363,7 @@ pub struct KeysConfig { pub navigate_pane_up: BindingConfig, /// Focus the pane to the right in navigate mode. Default: "l". Right arrow is always an alias. pub navigate_pane_right: BindingConfig, - /// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q". + /// Detach the current client from its Herdr server. Default: "prefix+q". pub detach: BindingConfig, /// Reload config.toml in the running app/server. Default: "prefix+shift+r". pub reload_config: BindingConfig, @@ -593,6 +584,12 @@ pub(crate) struct KeysConfigOverlay { command: Option>, } +impl KeysConfigOverlay { + pub(crate) fn set_prefix(&mut self, prefix: String) { + self.prefix = Some(prefix); + } +} + impl<'de> Deserialize<'de> for KeysConfig { fn deserialize(deserializer: D) -> Result where diff --git a/src/config/write.rs b/src/config/write.rs new file mode 100644 index 00000000..75244583 --- /dev/null +++ b/src/config/write.rs @@ -0,0 +1,75 @@ +#[derive(Clone, Copy)] +pub(crate) enum ConfigEdit<'a> { + Theme(&'a str), + StatusIndicators(super::StatusIndicatorStyle), + Sound(bool), + ToastDelivery(super::ToastDelivery), +} + +impl ConfigEdit<'_> { + pub(crate) fn description(self) -> &'static str { + match self { + Self::Theme(_) => "theme", + Self::StatusIndicators(_) => "status indicators", + Self::Sound(_) => "sound setting", + Self::ToastDelivery(_) => "toast setting", + } + } + + pub(crate) fn apply(self, content: &str) -> String { + match self { + Self::Theme(name) => { + let content = + super::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")); + super::upsert_section_bool(&content, "theme", "auto_switch", false) + } + Self::StatusIndicators(style) => super::upsert_section_value( + content, + "ui", + "status_indicators", + &format!("\"{}\"", style.as_str()), + ), + Self::Sound(enabled) => { + super::upsert_section_bool(content, "ui.sound", "enabled", enabled) + } + Self::ToastDelivery(delivery) => { + let value = match delivery { + super::ToastDelivery::Off => "\"off\"", + super::ToastDelivery::Herdr => "\"herdr\"", + super::ToastDelivery::Terminal => "\"terminal\"", + super::ToastDelivery::System => "\"system\"", + }; + let content = super::upsert_section_value(content, "ui.toast", "delivery", value); + super::remove_section_key(&content, "ui.toast", "enabled") + } + } + } +} + +pub(crate) fn update_file_at( + path: &std::path::Path, + description: &str, + update: impl FnOnce(&str) -> String, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create config directory: {error}"))?; + } + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => { + return Err(format!( + "failed to read config before saving {description}: {error}" + )); + } + }; + std::fs::write(path, update(&content)) + .map_err(|error| format!("failed to save {description}: {error}")) +} + +pub(crate) fn write_edit(edit: ConfigEdit<'_>) -> Result<(), String> { + update_file_at(&super::config_path(), edit.description(), |content| { + edit.apply(content) + }) +} diff --git a/src/copy_mode.rs b/src/copy_mode.rs new file mode 100644 index 00000000..8f84b9a8 --- /dev/null +++ b/src/copy_mode.rs @@ -0,0 +1,86 @@ +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::input::TerminalKey; + +pub(crate) fn first_non_blank_col(text: &str) -> Option { + let mut col = 0u16; + for ch in text.chars() { + if !ch.is_whitespace() { + return Some(col); + } + col = col.saturating_add(char_cell_width(ch)); + } + None +} + +pub(crate) fn last_character_col(text: &str) -> Option { + let mut col = 0u16; + let mut last_col = None; + for ch in text.chars() { + let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); + if width > 0 { + last_col = Some(col); + col = col.saturating_add(width); + } + } + last_col +} + +fn char_cell_width(ch: char) -> u16 { + u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) +} + +pub(crate) fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { + if height <= 2 { + 1 + } else if half_page { + usize::from(height / 2) + } else { + usize::from(height - 2) + } +} + +pub(crate) fn copy_mode_command_char(key: TerminalKey) -> Option { + if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + return None; + } + if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) { + return Some(ch); + } + let KeyCode::Char(ch) = key.code else { + return None; + }; + if key.modifiers.contains(KeyModifiers::SHIFT) { + Some(shifted_ascii_char(ch).unwrap_or(ch)) + } else { + Some(ch) + } +} + +fn shifted_ascii_char(ch: char) -> Option { + match ch { + 'a'..='z' => Some(ch.to_ascii_uppercase()), + '1' => Some('!'), + '2' => Some('@'), + '3' => Some('#'), + '4' => Some('$'), + '5' => Some('%'), + '6' => Some('^'), + '7' => Some('&'), + '8' => Some('*'), + '9' => Some('('), + '0' => Some(')'), + '-' => Some('_'), + '=' => Some('+'), + '[' => Some('{'), + ']' => Some('}'), + '\\' => Some('|'), + ';' => Some(':'), + '\'' => Some('"'), + ',' => Some('<'), + '.' => Some('>'), + '/' => Some('?'), + '`' => Some('~'), + _ => None, + } +} diff --git a/src/events.rs b/src/events.rs index cffcf03d..d17ef60b 100644 --- a/src/events.rs +++ b/src/events.rs @@ -137,11 +137,6 @@ pub enum AppEvent { /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. ClipboardWrite { content: Vec }, - /// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input - /// realm. The foreground process applies the host-local TIS switch (`active = true`) / - /// restore (`active = false`): the client in server mode (via server forwarding), the - /// app itself in monolithic mode. - PrefixInputSource { active: bool }, /// A pane child reported its shell current directory through terminal /// metadata such as OSC 7. TerminalCwdReported { diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs new file mode 100644 index 00000000..e9462b9d --- /dev/null +++ b/src/input/keybind_help.rs @@ -0,0 +1,269 @@ +use std::borrow::Cow; + +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + config::{ActionKeybinds, IndexedKeybind, Keybinds}, + input::TerminalKey, +}; + +pub(crate) type KeybindHelpEntry = (String, Cow<'static, str>); +pub(crate) type KeybindHelpGroup = (&'static str, Vec); + +pub(crate) fn keybind_help_text_char(key: &TerminalKey) -> Option { + if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + return None; + } + if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { + return Some(character); + } + let KeyCode::Char(character) = key.code else { + return None; + }; + Some(character) +} + +fn entry(key: impl Into, label: &'static str) -> KeybindHelpEntry { + (key.into(), Cow::Borrowed(label)) +} + +fn binding_label(bindings: &ActionKeybinds) -> String { + bindings.label().unwrap_or_else(|| "unset".to_owned()) +} + +fn indexed_label(bindings: &[IndexedKeybind]) -> String { + if bindings.is_empty() { + return "unset".to_owned(); + } + let mut parts = Vec::new(); + let mut index = 0; + while index < bindings.len() { + if let Some(prefix) = indexed_range_prefix(&bindings[index..]) { + parts.push(format!("{prefix}1..9")); + index += 9; + } else { + parts.push(bindings[index].label.clone()); + index += 1; + } + } + parts.join(" / ") +} + +fn indexed_range_prefix(bindings: &[IndexedKeybind]) -> Option<&str> { + let run = bindings.get(..9)?; + let prefix = run[0].label.strip_suffix('1')?; + for (offset, binding) in run.iter().enumerate() { + let digit = char::from(b'1' + offset as u8); + if binding.label.strip_suffix(digit) != Some(prefix) { + return None; + } + } + Some(prefix) +} + +pub(crate) fn keybind_help_groups( + keybinds: &Keybinds, + prefix: (crossterm::event::KeyCode, crossterm::event::KeyModifiers), +) -> Vec { + let mut groups = vec![ + ( + "global", + vec![ + entry(crate::config::format_key_combo(prefix), "prefix mode"), + entry(binding_label(&keybinds.help), "keybinds"), + entry(binding_label(&keybinds.settings), "settings"), + entry(binding_label(&keybinds.detach), "detach"), + entry(binding_label(&keybinds.reload_config), "reload config"), + entry( + binding_label(&keybinds.open_notification_target), + "open notification target", + ), + ], + ), + ( + "navigation", + vec![ + entry("esc", "back"), + entry( + format!( + "{} / {}", + binding_label(&keybinds.navigate.workspace_up), + binding_label(&keybinds.navigate.workspace_down) + ), + "workspace list", + ), + entry( + format!( + "{} / {} / {} / {} / left / right", + binding_label(&keybinds.navigate.pane_left), + binding_label(&keybinds.navigate.pane_down), + binding_label(&keybinds.navigate.pane_up), + binding_label(&keybinds.navigate.pane_right) + ), + "move focus", + ), + entry("tab / shift+tab", "cycle pane"), + entry("enter", "open workspace"), + entry("1..9", "switch workspace"), + ], + ), + ( + "workspaces / tabs", + vec![ + entry( + binding_label(&keybinds.workspace_picker), + "workspace navigation", + ), + entry(binding_label(&keybinds.goto), "session navigator"), + entry(binding_label(&keybinds.new_workspace), "new workspace"), + entry(binding_label(&keybinds.new_worktree), "new worktree"), + entry(binding_label(&keybinds.open_worktree), "open worktree"), + entry( + binding_label(&keybinds.remove_worktree), + "delete worktree checkout", + ), + entry( + binding_label(&keybinds.rename_workspace), + "rename workspace", + ), + entry(binding_label(&keybinds.close_workspace), "close workspace"), + entry( + binding_label(&keybinds.previous_workspace), + "previous workspace", + ), + entry(binding_label(&keybinds.next_workspace), "next workspace"), + entry( + indexed_label(&keybinds.switch_workspace), + "switch workspace 1-9", + ), + entry(binding_label(&keybinds.previous_agent), "previous agent"), + entry(binding_label(&keybinds.next_agent), "next agent"), + entry(indexed_label(&keybinds.focus_agent), "focus agent 1-9"), + entry(binding_label(&keybinds.new_tab), "new tab"), + entry(binding_label(&keybinds.rename_tab), "rename tab"), + entry(binding_label(&keybinds.previous_tab), "previous tab"), + entry(binding_label(&keybinds.next_tab), "next tab"), + entry(binding_label(&keybinds.move_tab_previous), "move tab left"), + entry(binding_label(&keybinds.move_tab_next), "move tab right"), + entry(indexed_label(&keybinds.switch_tab), "switch tab 1-9"), + entry(binding_label(&keybinds.close_tab), "close tab"), + ], + ), + ( + "panes", + vec![ + entry(binding_label(&keybinds.split_vertical), "split vertical"), + entry( + binding_label(&keybinds.split_horizontal), + "split horizontal", + ), + entry(binding_label(&keybinds.close_pane), "close pane"), + entry(binding_label(&keybinds.rename_pane), "rename pane"), + entry(binding_label(&keybinds.edit_scrollback), "edit scrollback"), + entry(binding_label(&keybinds.copy_mode), "copy mode"), + entry(binding_label(&keybinds.zoom), "zoom pane"), + entry(binding_label(&keybinds.resize_mode), "resize mode"), + entry( + binding_label(&keybinds.resize_pane_left), + "resize pane left", + ), + entry( + binding_label(&keybinds.resize_pane_down), + "resize pane down", + ), + entry(binding_label(&keybinds.resize_pane_up), "resize pane up"), + entry( + binding_label(&keybinds.resize_pane_right), + "resize pane right", + ), + entry(binding_label(&keybinds.toggle_sidebar), "toggle sidebar"), + entry(binding_label(&keybinds.focus_pane_left), "focus pane left"), + entry(binding_label(&keybinds.focus_pane_down), "focus pane down"), + entry(binding_label(&keybinds.focus_pane_up), "focus pane up"), + entry( + binding_label(&keybinds.focus_pane_right), + "focus pane right", + ), + entry(binding_label(&keybinds.cycle_pane_next), "cycle pane next"), + entry( + binding_label(&keybinds.cycle_pane_previous), + "cycle pane previous", + ), + entry(binding_label(&keybinds.last_pane), "last pane"), + ], + ), + ]; + + if !keybinds.custom_commands.is_empty() { + groups.push(( + "custom", + keybinds + .custom_commands + .iter() + .map(|binding| { + ( + binding.label.clone(), + binding + .description + .clone() + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed("custom command")), + ) + }) + .collect(), + )); + } + groups +} + +pub(crate) fn filter_keybind_help_groups( + groups: Vec, + query: &str, +) -> Vec { + if query.is_empty() { + return groups; + } + let query = query.to_lowercase(); + groups + .into_iter() + .filter_map(|(group, entries)| { + let entries = entries + .into_iter() + .filter(|(key, label)| { + key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) + }) + .collect::>(); + (!entries.is_empty()).then_some((group, entries)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn groups() -> Vec { + vec![ + ( + "workspaces / tabs", + vec![entry("w", "workspace navigation"), entry("c", "new tab")], + ), + ( + "panes", + vec![entry("v", "split vertical"), entry("x", "close pane")], + ), + ] + } + + #[test] + fn filter_matches_labels_and_shortcuts_case_insensitively() { + let filtered = filter_keybind_help_groups(groups(), "WoRk"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].1[0].1, "workspace navigation"); + + let filtered = filter_keybind_help_groups(groups(), "x"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].1[0].1, "close pane"); + assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); + } +} diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs new file mode 100644 index 00000000..d56de80c --- /dev/null +++ b/src/input/keybindings.rs @@ -0,0 +1,298 @@ +use crossterm::event::KeyCode; + +use crate::config::{CustomCommandKeybind, Keybinds}; + +use super::TerminalKey; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeybindDispatch { + Direct, + Prefix, +} + +#[derive(Debug, Clone)] +pub(crate) enum KeybindMatch { + Action(KeybindAction), + Command(CustomCommandKeybind), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeybindAction { + NewWorkspace, + NewWorktree, + OpenWorktree, + RemoveWorktree, + RenameWorkspace, + CloseWorkspace, + SwitchWorkspace(usize), + SwitchTab(usize), + FocusAgent(usize), + WorkspacePicker, + PreviousWorkspace, + NextWorkspace, + PreviousAgent, + NextAgent, + NewTab, + RenameTab, + PreviousTab, + NextTab, + MoveTabPrevious, + MoveTabNext, + CloseTab, + RenamePane, + FocusPaneLeft, + FocusPaneDown, + FocusPaneUp, + FocusPaneRight, + SwapPaneLeft, + SwapPaneDown, + SwapPaneUp, + SwapPaneRight, + SplitVertical, + SplitHorizontal, + ClosePane, + EditScrollback, + CopyMode, + Zoom, + EnterResizeMode, + ResizePaneLeft, + ResizePaneDown, + ResizePaneUp, + ResizePaneRight, + ToggleSidebar, + CyclePaneNext, + CyclePanePrevious, + LastPane, + Help, + Settings, + ReloadConfig, + OpenNotificationTarget, + Detach, + OpenNavigator, +} + +pub(crate) fn resolve_direct_binding( + keybinds: &Keybinds, + key: &TerminalKey, +) -> Option { + resolve_exact_binding(keybinds, key, KeybindDispatch::Direct) +} + +pub(crate) fn resolve_prefix_binding( + keybinds: &Keybinds, + key: &TerminalKey, +) -> Option { + resolve_exact_binding(keybinds, key, KeybindDispatch::Prefix).or_else(|| { + generated_character_key(key).and_then(|generated_key| { + resolve_exact_binding(keybinds, &generated_key, KeybindDispatch::Prefix) + }) + }) +} + +pub(crate) fn resolve_non_indexed_action( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option { + for (bindings, action) in [ + (&keybinds.help, KeybindAction::Help), + (&keybinds.settings, KeybindAction::Settings), + (&keybinds.workspace_picker, KeybindAction::WorkspacePicker), + (&keybinds.new_workspace, KeybindAction::NewWorkspace), + (&keybinds.new_worktree, KeybindAction::NewWorktree), + (&keybinds.open_worktree, KeybindAction::OpenWorktree), + (&keybinds.remove_worktree, KeybindAction::RemoveWorktree), + (&keybinds.rename_workspace, KeybindAction::RenameWorkspace), + (&keybinds.close_workspace, KeybindAction::CloseWorkspace), + ( + &keybinds.previous_workspace, + KeybindAction::PreviousWorkspace, + ), + (&keybinds.next_workspace, KeybindAction::NextWorkspace), + (&keybinds.previous_agent, KeybindAction::PreviousAgent), + (&keybinds.next_agent, KeybindAction::NextAgent), + (&keybinds.new_tab, KeybindAction::NewTab), + (&keybinds.rename_tab, KeybindAction::RenameTab), + (&keybinds.previous_tab, KeybindAction::PreviousTab), + (&keybinds.next_tab, KeybindAction::NextTab), + (&keybinds.move_tab_previous, KeybindAction::MoveTabPrevious), + (&keybinds.move_tab_next, KeybindAction::MoveTabNext), + (&keybinds.close_tab, KeybindAction::CloseTab), + (&keybinds.rename_pane, KeybindAction::RenamePane), + (&keybinds.edit_scrollback, KeybindAction::EditScrollback), + (&keybinds.copy_mode, KeybindAction::CopyMode), + (&keybinds.focus_pane_left, KeybindAction::FocusPaneLeft), + (&keybinds.focus_pane_down, KeybindAction::FocusPaneDown), + (&keybinds.focus_pane_up, KeybindAction::FocusPaneUp), + (&keybinds.focus_pane_right, KeybindAction::FocusPaneRight), + (&keybinds.swap_pane_left, KeybindAction::SwapPaneLeft), + (&keybinds.swap_pane_down, KeybindAction::SwapPaneDown), + (&keybinds.swap_pane_up, KeybindAction::SwapPaneUp), + (&keybinds.swap_pane_right, KeybindAction::SwapPaneRight), + (&keybinds.last_pane, KeybindAction::LastPane), + (&keybinds.cycle_pane_next, KeybindAction::CyclePaneNext), + ( + &keybinds.cycle_pane_previous, + KeybindAction::CyclePanePrevious, + ), + (&keybinds.split_vertical, KeybindAction::SplitVertical), + (&keybinds.split_horizontal, KeybindAction::SplitHorizontal), + (&keybinds.close_pane, KeybindAction::ClosePane), + (&keybinds.zoom, KeybindAction::Zoom), + (&keybinds.resize_mode, KeybindAction::EnterResizeMode), + (&keybinds.resize_pane_left, KeybindAction::ResizePaneLeft), + (&keybinds.resize_pane_down, KeybindAction::ResizePaneDown), + (&keybinds.resize_pane_up, KeybindAction::ResizePaneUp), + (&keybinds.resize_pane_right, KeybindAction::ResizePaneRight), + (&keybinds.toggle_sidebar, KeybindAction::ToggleSidebar), + (&keybinds.reload_config, KeybindAction::ReloadConfig), + ( + &keybinds.open_notification_target, + KeybindAction::OpenNotificationTarget, + ), + (&keybinds.detach, KeybindAction::Detach), + (&keybinds.goto, KeybindAction::OpenNavigator), + ] { + if action_matches(bindings, key, dispatch) { + return Some(action); + } + } + None +} + +pub(crate) fn resolve_custom_command( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option { + keybinds + .custom_commands + .iter() + .find(|binding| match dispatch { + KeybindDispatch::Direct => binding.bindings.matches_direct_key(key), + KeybindDispatch::Prefix => binding.bindings.matches_prefix_key(key), + }) + .cloned() +} + +pub(crate) fn resolve_indexed_action( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option { + let actual_modifiers = crate::config::normalize_key_combo((key.code, key.modifiers)).1; + + for exact_modifiers in [true, false] { + let trigger_matches = |binding: &crate::config::IndexedKeybind| { + let dispatch_matches = match dispatch { + KeybindDispatch::Direct => binding.trigger.is_direct(), + KeybindDispatch::Prefix => binding.trigger.is_prefix(), + }; + let expected_modifiers = crate::config::normalize_key_combo(binding.trigger.combo()).1; + dispatch_matches && (actual_modifiers == expected_modifiers) == exact_modifiers + }; + + for binding in &keybinds.switch_tab { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::SwitchTab(index)); + } + } + } + for binding in &keybinds.switch_workspace { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::SwitchWorkspace(index)); + } + } + } + for binding in &keybinds.focus_agent { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::FocusAgent(index)); + } + } + } + } + + None +} + +fn resolve_exact_binding( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option { + resolve_non_indexed_action(keybinds, key, dispatch) + .map(KeybindMatch::Action) + .or_else(|| resolve_custom_command(keybinds, key, dispatch).map(KeybindMatch::Command)) + .or_else(|| resolve_indexed_action(keybinds, key, dispatch).map(KeybindMatch::Action)) +} + +fn generated_character_key(key: &TerminalKey) -> Option { + let mut characters = key.generated_text.as_deref()?.chars(); + let character = characters.next()?; + if character.is_control() || characters.next().is_some() { + return None; + } + Some(TerminalKey::new( + KeyCode::Char(character), + crossterm::event::KeyModifiers::empty(), + )) +} + +fn action_matches( + bindings: &crate::config::ActionKeybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> bool { + match dispatch { + KeybindDispatch::Direct => bindings.matches_direct_key(key), + KeybindDispatch::Prefix => bindings.matches_prefix_key(key), + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn one_shared_resolver_handles_direct_prefix_and_indexed_bindings() { + let keybinds = Keybinds { + next_tab: crate::config::ActionKeybinds::direct("ctrl+n"), + ..Keybinds::default() + }; + + let direct = TerminalKey::new(KeyCode::Char('n'), KeyModifiers::CONTROL); + assert!(matches!( + resolve_direct_binding(&keybinds, &direct), + Some(KeybindMatch::Action(KeybindAction::NextTab)) + )); + + let help = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::empty()); + assert!(matches!( + resolve_prefix_binding(&keybinds, &help), + Some(KeybindMatch::Action(KeybindAction::Help)) + )); + + let one = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::empty()); + assert!(matches!( + resolve_prefix_binding(&keybinds, &one), + Some(KeybindMatch::Action(KeybindAction::SwitchTab(0))) + )); + } + + #[test] + fn prefix_resolution_uses_shared_generated_character_fallback() { + let keybinds = Keybinds::default(); + let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("?".to_owned())); + + assert!(matches!( + resolve_prefix_binding(&keybinds, &key), + Some(KeybindMatch::Action(KeybindAction::Help)) + )); + } +} diff --git a/src/app/input/lease.rs b/src/input/lease.rs similarity index 67% rename from src/app/input/lease.rs rename to src/input/lease.rs index 6724b804..0352df42 100644 --- a/src/app/input/lease.rs +++ b/src/input/lease.rs @@ -1,64 +1,74 @@ use std::collections::HashMap; +use std::hash::Hash; -use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; -use crate::input::{KeyIdentity, TerminalKey}; +use super::{KeyIdentity, TerminalKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) struct InputLeaseKey { - source_id: InputSourceId, +pub(crate) struct InputLeaseKey { + source: Source, identity: KeyIdentity, } -impl InputLeaseKey { - pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self { +impl InputLeaseKey { + pub(crate) fn new(source: Source, key: &TerminalKey) -> Self { Self { - source_id, + source, identity: key.identity(), } } } #[derive(Debug, PartialEq, Eq)] -pub(crate) struct ForwardedInputLease { - pub(crate) target: TerminalInputTarget, +pub(crate) struct ForwardedInputLease { + pub(crate) target: Target, pub(crate) key: TerminalKey, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ConsumedInputLease { - ReprocessRepeats(TerminalInputContext), +pub(crate) enum ConsumedInputLease { + ReprocessRepeats(Context), SuppressRepeats, } #[derive(Debug, PartialEq, Eq)] -pub(crate) enum InputLease { - Forwarded(ForwardedInputLease), - Consumed(ConsumedInputLease), +pub(crate) enum InputLease { + Forwarded(ForwardedInputLease), + Consumed(ConsumedInputLease), } -pub(crate) enum RepeatPlan { - Forwarded(TerminalInputTarget), +pub(crate) enum RepeatPlan { + Forwarded(Target), Reprocess { - context: TerminalInputContext, + context: Context, repetitions: u16, tracked: bool, }, Ignore, } -#[derive(Default)] -pub(crate) struct InputLeaseTable { - leases: HashMap, +pub(crate) struct InputLeaseTable { + leases: HashMap, InputLease>, } -impl InputLeaseTable { +impl Default for InputLeaseTable { + fn default() -> Self { + Self { + leases: HashMap::new(), + } + } +} + +impl InputLeaseTable +where + Source: Copy + Eq + Hash, + Context: Clone + Eq, + Target: Clone + Eq, +{ pub(crate) fn normalize_press( &mut self, - lease_key: &InputLeaseKey, + lease_key: &InputLeaseKey, key: TerminalKey, ) -> TerminalKey { - // Generated text is normally stateless, but a native physical key still owns - // press/repeat/release lifecycle even when it carries a layout result. if key.kind != crossterm::event::KeyEventKind::Press || (key.generated_text.is_some() && !key.has_physical_identity()) { @@ -74,12 +84,12 @@ impl InputLeaseTable { pub(crate) fn complete_press( &mut self, - lease_key: InputLeaseKey, + lease_key: InputLeaseKey, key: &TerminalKey, - initial_context: Option<&TerminalInputContext>, - resulting_context: Option<&TerminalInputContext>, - target: Option, - ) -> RepeatPlan { + initial_context: Option<&Context>, + resulting_context: Option<&Context>, + target: Option, + ) -> RepeatPlan { if key.generated_text.is_some() && !key.has_physical_identity() { return RepeatPlan::Ignore; } @@ -112,10 +122,10 @@ impl InputLeaseTable { pub(crate) fn plan_repeat( &mut self, - lease_key: InputLeaseKey, + lease_key: InputLeaseKey, key: &TerminalKey, - current_context: Option<&TerminalInputContext>, - ) -> RepeatPlan { + current_context: Option<&Context>, + ) -> RepeatPlan { match self.leases.get(&lease_key) { Some(InputLease::Forwarded(lease)) => { return RepeatPlan::Forwarded(lease.target.clone()); @@ -150,9 +160,9 @@ impl InputLeaseTable { pub(crate) fn reprocess_allowed( &mut self, - lease_key: InputLeaseKey, - expected_context: &TerminalInputContext, - current_context: Option<&TerminalInputContext>, + lease_key: InputLeaseKey, + expected_context: &Context, + current_context: Option<&Context>, tracked: bool, ) -> bool { let allowed = current_context == Some(expected_context); @@ -162,7 +172,10 @@ impl InputLeaseTable { allowed } - pub(crate) fn remove_forwarded(&mut self, key: &InputLeaseKey) -> Option { + pub(crate) fn remove_forwarded( + &mut self, + key: &InputLeaseKey, + ) -> Option> { match self.leases.remove(key) { Some(InputLease::Forwarded(lease)) => Some(lease), Some(InputLease::Consumed(_)) | None => None, @@ -170,14 +183,14 @@ impl InputLeaseTable { } #[cfg(test)] - pub(crate) fn contains(&self, key: &InputLeaseKey) -> bool { + pub(crate) fn contains(&self, key: &InputLeaseKey) -> bool { self.leases.contains_key(key) } pub(crate) fn insert_forwarded( &mut self, - key: InputLeaseKey, - target: TerminalInputTarget, + key: InputLeaseKey, + target: Target, original: TerminalKey, ) { self.leases.insert( @@ -189,28 +202,32 @@ impl InputLeaseTable { ); } - pub(crate) fn insert_consumed(&mut self, key: InputLeaseKey, disposition: ConsumedInputLease) { + pub(crate) fn insert_consumed( + &mut self, + key: InputLeaseKey, + disposition: ConsumedInputLease, + ) { self.leases.insert(key, InputLease::Consumed(disposition)); } - pub(crate) fn remove(&mut self, key: &InputLeaseKey) -> Option { + pub(crate) fn remove( + &mut self, + key: &InputLeaseKey, + ) -> Option> { self.leases.remove(key) } - pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec { + pub(crate) fn remove_source(&mut self, source: Source) -> Vec> { let keys = self .leases .keys() - .filter(|key| key.source_id == source_id) + .filter(|key| key.source == source) .copied() .collect::>(); self.remove_keys(keys) } - pub(crate) fn remove_target( - &mut self, - target: &TerminalInputTarget, - ) -> Vec { + pub(crate) fn remove_target(&mut self, target: &Target) -> Vec> { let keys = self .leases .iter() @@ -224,8 +241,8 @@ impl InputLeaseTable { fn remove_keys( &mut self, - keys: impl IntoIterator, - ) -> Vec { + keys: impl IntoIterator>, + ) -> Vec> { keys.into_iter() .filter_map(|key| match self.leases.remove(&key) { Some(InputLease::Forwarded(lease)) => Some(lease), @@ -251,12 +268,13 @@ mod tests { use super::*; - fn target() -> TerminalInputTarget { - TerminalInputTarget { - terminal_id: crate::terminal::TerminalId::alloc(), - } + #[derive(Clone, Debug, PartialEq, Eq)] + enum Context { + Pane, } + type Leases = InputLeaseTable; + fn physical_generated_slash(repeat_count: u16) -> TerminalKey { TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) .with_generated_text(Some("/".to_owned())) @@ -279,40 +297,31 @@ mod tests { &TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL), ); let other_source = InputLeaseKey::new(8, &key); - let first_target = target(); - let mut leases = InputLeaseTable::default(); - leases.insert_forwarded(forwarded, first_target.clone(), key.clone()); + let mut leases = Leases::default(); + leases.insert_forwarded(forwarded, 10, key.clone()); leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); - leases.insert_forwarded(other_source, target(), key.clone()); + leases.insert_forwarded(other_source, 11, key.clone()); assert_eq!( leases.remove_source(7), - vec![ForwardedInputLease { - target: first_target, - key, - }] + vec![ForwardedInputLease { target: 10, key }] ); assert_eq!(leases.len(), 1); assert!(leases.contains(&other_source)); } #[test] - fn remove_target_closes_only_forwarded_leases_for_that_terminal() { + fn remove_target_closes_only_forwarded_leases_for_that_target() { let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); let removed_key = InputLeaseKey::new(7, &key); let retained_key = InputLeaseKey::new(8, &key); - let removed_target = target(); - let retained_target = target(); - let mut leases = InputLeaseTable::default(); - leases.insert_forwarded(removed_key, removed_target.clone(), key.clone()); - leases.insert_forwarded(retained_key, retained_target, key.clone()); + let mut leases = Leases::default(); + leases.insert_forwarded(removed_key, 10, key.clone()); + leases.insert_forwarded(retained_key, 11, key.clone()); assert_eq!( - leases.remove_target(&removed_target), - vec![ForwardedInputLease { - target: removed_target, - key, - }] + leases.remove_target(&10), + vec![ForwardedInputLease { target: 10, key }] ); assert_eq!(leases.len(), 1); assert!(leases.contains(&retained_key)); @@ -331,7 +340,7 @@ mod tests { let physical = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); let lease_key = InputLeaseKey::new(7, &physical); - let mut leases = InputLeaseTable::default(); + let mut leases = Leases::default(); assert_eq!( leases.normalize_press(&lease_key, physical.clone()).kind, @@ -342,9 +351,9 @@ mod tests { leases.normalize_press(&lease_key, physical.clone()).kind, crossterm::event::KeyEventKind::Repeat ); - leases.insert_forwarded(lease_key, target(), physical.clone()); + leases.insert_forwarded(lease_key, 10, physical.clone()); assert_eq!( - leases.normalize_press(&lease_key, physical.clone()).kind, + leases.normalize_press(&lease_key, physical).kind, crossterm::event::KeyEventKind::Repeat ); } @@ -353,32 +362,18 @@ mod tests { fn physical_generated_text_keeps_native_repeat_lifecycle() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let forwarded_target = target(); - let mut leases = InputLeaseTable::default(); + let context = Context::Pane; + let mut leases = Leases::default(); - assert_eq!( - leases.normalize_press(&lease_key, key.clone()).kind, - crossterm::event::KeyEventKind::Press - ); assert!(matches!( - leases.complete_press( - lease_key, - &key, - Some(&context), - Some(&context), - Some(forwarded_target.clone()), - ), + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10),), RepeatPlan::Ignore )); - assert!(leases.contains(&lease_key)); - - let repeated = key.with_repeat_count(1); - let repeated = leases.normalize_press(&lease_key, repeated); + let repeated = leases.normalize_press(&lease_key, key.with_repeat_count(1)); assert_eq!(repeated.kind, crossterm::event::KeyEventKind::Repeat); assert!(matches!( leases.plan_repeat(lease_key, &repeated, Some(&context)), - RepeatPlan::Forwarded(target) if target == forwarded_target + RepeatPlan::Forwarded(10) )); assert!(leases.remove_forwarded(&lease_key).is_some()); } @@ -387,13 +382,13 @@ mod tests { fn consumed_grouped_physical_generated_text_reprocesses_repeats() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); + let context = Context::Pane; + let mut leases = Leases::default(); assert!(matches!( leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: Context::Pane, repetitions: 2, tracked: true, } @@ -401,42 +396,34 @@ mod tests { } #[test] - fn semantic_generated_text_remains_untracked() { + fn forwarded_semantic_generated_text_has_no_release_lease() { let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) .with_generated_text(Some("/".to_owned())) .with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); + let context = Context::Pane; + let mut leases = Leases::default(); assert!(matches!( - leases.complete_press( - lease_key, - &key, - Some(&context), - Some(&context), - Some(target()) - ), + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)), RepeatPlan::Ignore )); - assert!(leases.is_empty()); + assert_eq!(leases.remove_forwarded(&lease_key), None); } #[test] fn new_semantic_press_recomputes_consumed_repeat_disposition() { let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); + let context = Context::Pane; + let mut leases = Leases::default(); leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); let key = leases.normalize_press(&lease_key, key); - let plan = leases.complete_press(lease_key, &key, Some(&context), Some(&context), None); - assert!(matches!( - plan, + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: Context::Pane, repetitions: 2, tracked: true, } diff --git a/src/input/mod.rs b/src/input/mod.rs index 8108bf99..0cd6c4bd 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,7 @@ mod encode; +mod keybind_help; +mod keybindings; +mod lease; mod model; pub(crate) mod mouse; mod parse; @@ -7,12 +10,23 @@ mod parse; pub use encode::{ encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, }; +pub(crate) use keybind_help::{ + filter_keybind_help_groups, keybind_help_groups, keybind_help_text_char, +}; +pub(crate) use keybindings::{ + resolve_custom_command, resolve_direct_binding, resolve_indexed_action, + resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch, + KeybindMatch, +}; +pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan}; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; #[cfg(any(unix, test))] pub use model::MouseProtocolMode; +#[cfg(any(windows, test))] +pub use model::WindowsKeyRecord; pub use model::{ host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey, - TextCommit, WindowsKeyRecord, + TextCommit, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index cada5506..e45ef378 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,6 +3,7 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowsKeyRecord { pub key_down: bool, @@ -32,11 +33,13 @@ impl TextCommit { } } +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PhysicalKeyId(u32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyIdentity { + #[cfg(any(windows, test))] Physical(PhysicalKeyId), Semantic(KeyCode), } @@ -47,12 +50,14 @@ pub(crate) enum KeySource { Vt { bytes: Vec, }, + #[cfg(any(windows, test))] WindowsConsole { record: WindowsKeyRecord, physical_key: Option, }, } +#[cfg(any(windows, test))] impl WindowsKeyRecord { fn physical_key_id(self) -> Option { const ENHANCED_KEY: u32 = 0x0100; @@ -73,6 +78,7 @@ pub struct TerminalKey { pub repeat_count: u16, pub shifted_codepoint: Option, pub generated_text: Option, + physical_identity_hint: bool, source: KeySource, } @@ -85,6 +91,7 @@ impl TerminalKey { repeat_count: 1, shifted_codepoint: None, generated_text: None, + physical_identity_hint: false, source: KeySource::Synthesized, } } @@ -112,7 +119,6 @@ impl TerminalKey { self } - #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { self.shifted_codepoint = Some(shifted_codepoint); self @@ -132,19 +138,27 @@ impl TerminalKey { self } + #[cfg(any(windows, test))] pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { 1 } else { record.repeat_count.max(1) }; + let physical_key = record.physical_key_id(); + self.physical_identity_hint = physical_key.is_some(); self.source = KeySource::WindowsConsole { - physical_key: record.physical_key_id(), + physical_key, record, }; self } + pub(crate) fn with_physical_identity_hint(mut self, physical: bool) -> Self { + self.physical_identity_hint = physical; + self + } + #[cfg(any(windows, test))] pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { match &self.source { @@ -163,26 +177,36 @@ impl TerminalKey { pub(crate) fn identity(&self) -> KeyIdentity { match self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: Some(physical_key), .. } => KeyIdentity::Physical(physical_key), + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: None, .. - } - | KeySource::Synthesized - | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + } => KeyIdentity::Semantic(self.code), + KeySource::Synthesized | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), } } pub(crate) fn has_physical_identity(&self) -> bool { - matches!( - self.source, + self.physical_identity_hint || self.physical_key_id().is_some() + } + + pub(crate) fn physical_key_id(&self) -> Option { + match &self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { - physical_key: Some(_), + physical_key: Some(PhysicalKeyId(id)), .. - } - ) + } => Some(*id), + #[cfg(any(windows, test))] + KeySource::WindowsConsole { + physical_key: None, .. + } => None, + KeySource::Synthesized | KeySource::Vt { .. } => None, + } } pub fn with_text_commit(mut self) -> Self { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 62b5f6c2..984537d1 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -47,10 +47,12 @@ impl HostGeometry { )) } + #[cfg(test)] fn column_boundary(self, column: u16) -> Option { boundary(column, self.cols, self.width_px) } + #[cfg(test)] fn row_boundary(self, row: u16) -> Option { boundary(row, self.rows, self.height_px) } @@ -63,28 +65,68 @@ impl HostPixels { child_width_px: u32, child_height_px: u32, ) -> Option { - let start_x = self.geometry.column_boundary(inner.x)?; - let start_y = self.geometry.row_boundary(inner.y)?; - let end_x = self - .geometry - .column_boundary(inner.x.checked_add(inner.width)?)?; - let end_y = self - .geometry - .row_boundary(inner.y.checked_add(inner.height)?)?; - let x = self.x.checked_sub(1)?.checked_sub(start_x)?; - let y = self.y.checked_sub(1)?.checked_sub(start_y)?; - let source_width = end_x.checked_sub(start_x)?; - let source_height = end_y.checked_sub(start_y)?; - if x >= source_width || y >= source_height || child_width_px == 0 || child_height_px == 0 { + let (host_column, host_row) = self.geometry.cell(self.x, self.y)?; + let end_column = inner.x.checked_add(inner.width)?; + let end_row = inner.y.checked_add(inner.height)?; + if host_column < inner.x + || host_column >= end_column + || host_row < inner.y + || host_row >= end_row + { return None; } Some(Position::Pixels { - x: scale(x, source_width, child_width_px).checked_add(1)?, - y: scale(y, source_height, child_height_px).checked_add(1)?, + x: map_axis_within_cell( + self.x, + host_column, + inner.x, + inner.width, + self.geometry.cols, + self.geometry.width_px, + child_width_px, + )?, + y: map_axis_within_cell( + self.y, + host_row, + inner.y, + inner.height, + self.geometry.rows, + self.geometry.height_px, + child_height_px, + )?, }) } } +fn map_axis_within_cell( + pixel: u32, + host_cell: u16, + pane_start: u16, + pane_cells: u16, + host_cells: u16, + host_extent: u32, + child_extent: u32, +) -> Option { + let local_cell = host_cell.checked_sub(pane_start)?; + if local_cell >= pane_cells { + return None; + } + let source_start = boundary(host_cell, host_cells, host_extent)?; + let source_end = boundary(host_cell.checked_add(1)?, host_cells, host_extent)?; + let target_start = boundary(local_cell, pane_cells, child_extent)?; + let target_end = boundary(local_cell.checked_add(1)?, pane_cells, child_extent)?; + let source_width = source_end.checked_sub(source_start)?; + let target_width = target_end.checked_sub(target_start)?; + let offset = pixel.checked_sub(1)?.checked_sub(source_start)?; + if source_width == 0 || target_width == 0 || offset >= source_width { + return None; + } + target_start + .checked_add(scale(offset, source_width, target_width))? + .checked_add(1) +} + +#[cfg(any(unix, test))] pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { let body = data.strip_prefix(b"\x1b[<")?; let body = body @@ -97,6 +139,7 @@ pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { fields.next().is_none().then_some((x, y)) } +#[cfg(any(unix, test))] pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option> { let body = data.strip_prefix(b"\x1b[<")?; let suffix = if body.ends_with(b"M") { 'M' } else { 'm' }; @@ -114,6 +157,7 @@ pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option Option { (!value.is_empty() && value.iter().all(u8::is_ascii_digit)) .then(|| std::str::from_utf8(value).ok()?.parse().ok()) @@ -183,6 +227,21 @@ mod tests { ); } + #[test] + fn fractional_scaling_preserves_the_canonical_child_cell() { + let geometry = HostGeometry::new(80, 1, 805, 20).unwrap(); + assert_eq!(geometry.cell(11, 1), Some((1, 0))); + assert_eq!( + HostPixels { + x: 11, + y: 1, + geometry, + } + .pane_position(ratatui::layout::Rect::new(0, 0, 80, 1), 800, 20), + Some(Position::Pixels { x: 11, y: 1 }) + ); + } + #[test] fn geometry_rejects_outside_pixels_and_maps_cells() { let geometry = HostGeometry::new(80, 24, 800, 480).unwrap(); diff --git a/src/integration/types.rs b/src/integration/types.rs index c7eaf795..2eaf7bf5 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -164,15 +164,6 @@ impl IntegrationRecommendation { self.state == IntegrationStatusKind::Outdated || (self.available && self.state == IntegrationStatusKind::NotInstalled) } - - pub fn status_label(&self) -> &'static str { - match (self.available, self.state) { - (_, IntegrationStatusKind::Current) => "installed", - (_, IntegrationStatusKind::Outdated) => "update available", - (true, IntegrationStatusKind::NotInstalled) => "available", - (false, IntegrationStatusKind::NotInstalled) => "not found", - } - } } #[derive(Debug)] diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index eece347c..1ce90eb3 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -2,21 +2,21 @@ use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use std::fmt::Write as FmtWrite; use std::hash::{Hash, Hasher}; -use std::io::{self, Write}; +use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; use base64::Engine; use ratatui::layout::Rect; use crate::app::state::AppState; -use crate::app::Mode; use crate::ghostty::{ KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, }; use crate::layout::{PaneId, PaneInfo}; use crate::terminal::TerminalRuntimeRegistry; +pub(crate) mod surface; + const KITTY_CHUNK_BYTES: usize = 3072; const MAX_OVERSIZED_SOURCES: usize = 256; pub(crate) const HEADLESS_GRAPHICS_TRANSACTION_BUDGET: usize = @@ -32,46 +32,9 @@ pub(crate) struct HostCellSize { } impl HostCellSize { - pub(crate) fn try_from_terminal(area: Rect) -> Option { - let Ok(size) = crossterm::terminal::window_size() else { - return None; - }; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some( - Self { - width_px: (size.width as u32 / size.columns as u32).max(1), - height_px: (size.height as u32 / size.rows as u32).max(1), - } - .for_area(area), - ) - } - pub(crate) fn is_known(self) -> bool { self.width_px > 0 && self.height_px > 0 } - - pub(crate) fn fallback_for_area(area: Rect) -> Self { - Self { - width_px: 8, - height_px: 16, - } - .for_area(area) - } - - fn for_area(self, area: Rect) -> Self { - if area.width == 0 || area.height == 0 { - return Self::default(); - } - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct HostViewKey { - workspace_index: usize, - tab_index: usize, } #[derive(Debug)] @@ -87,8 +50,18 @@ struct HostPlacement { #[derive(Debug, Clone, Hash, PartialEq, Eq)] enum HostSourceKey { - Terminal { pane_id: PaneId, image_id: u32 }, - PaneLayer { pane_id: PaneId, layer_id: String }, + Terminal { + pane_id: PaneId, + image_id: u32, + }, + PaneLayer { + pane_id: PaneId, + layer_id: String, + }, + ClientSurface { + scope: String, + source: crate::protocol::SurfaceGraphicsSource, + }, } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] @@ -138,13 +111,11 @@ pub(crate) struct HostGraphicsCache { sources: HashMap, oversized: HashMap, continuation: Option<(HostSourceKey, u32, usize)>, - view: Option, replay_placements: bool, replayed_placements: HashSet<(u32, u32)>, } static KITTY_GRAPHICS_ENABLED: AtomicBool = AtomicBool::new(false); -static LOCAL_HOST_GRAPHICS: OnceLock> = OnceLock::new(); pub(crate) fn set_enabled(enabled: bool) { KITTY_GRAPHICS_ENABLED.store(enabled, Ordering::Release); @@ -154,361 +125,11 @@ pub(crate) fn is_enabled() -> bool { KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) } -pub(crate) fn paint_local_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - cell_size: HostCellSize, -) -> io::Result<()> { - let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); - let Ok(mut cache) = cache.lock() else { - return Ok(()); - }; - if graphics.slots.is_empty() && !cache.has_pane_sources() { - let encoded = encode_local_pane_graphics( - app, - graphics, - terminal_runtimes, - app.view.tab_surface(), - cell_size, - None, - &mut cache, - ); - drop(cache); - if encoded.bytes.is_empty() { - return Ok(()); - } - let mut stdout = io::stdout().lock(); - stdout.write_all(b"\x1b7")?; - stdout.write_all(&encoded.bytes)?; - stdout.write_all(b"\x1b8")?; - return stdout.flush(); - } - - let mut stdout = io::stdout().lock(); - loop { - let encoded = encode_local_pane_graphics( - app, - graphics, - terminal_runtimes, - app.view.tab_surface(), - cell_size, - None, - &mut cache, - ); - if !encoded.bytes.is_empty() { - stdout.write_all(b"\x1b7")?; - stdout.write_all(&encoded.bytes)?; - stdout.write_all(b"\x1b8")?; - } - if !encoded.incomplete { - break; - } - } - stdout.flush() -} - pub(crate) struct EncodedGraphics { pub(crate) bytes: Vec, pub(crate) incomplete: bool, } -pub(crate) fn encode_local_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, - transaction_budget: Option, - cache: &mut HostGraphicsCache, -) -> EncodedGraphics { - let visible = app.mode == Mode::Terminal && cell_size.is_known(); - if graphics.slots.is_empty() { - if !visible { - return EncodedGraphics { - bytes: cache.clear_bytes(), - incomplete: false, - }; - } - let mut bytes = if transaction_budget.is_none() && cache.has_pane_sources() { - cache.clear_pane_sources() - } else { - Vec::new() - }; - let placements = collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ); - let view_changed = cache.update_view(active_view_key(app)); - let mut encoded = - encode_terminal_graphics_update(cache, &placements, view_changed, transaction_budget); - if !bytes.is_empty() { - bytes.extend(encoded.bytes); - encoded.bytes = bytes; - } - return encoded; - } - - let live_pane_sources = graphics - .slots - .iter() - .filter(|(_, slot)| slot.layer.is_some()) - .map(|((pane_id, layer_id), _)| HostSourceKey::PaneLayer { - pane_id: *pane_id, - layer_id: layer_id.clone(), - }) - .collect::>(); - let placements = if visible { - collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ) - } else { - Vec::new() - }; - cache.update_view(visible.then(|| active_view_key(app)).flatten()); - // The host text blit overwrites Kitty placements, so every rendered frame must - // display cached images again even when their data and geometry are unchanged. - cache.request_placement_replay(); - encode_graphics_update_incremental( - cache, - &placements, - &live_pane_sources, - transaction_budget, - false, - ) -} - -pub(crate) fn has_visible_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, -) -> bool { - if app.mode != Mode::Terminal || !cell_size.is_known() { - return false; - } - - let Some(ws_idx) = app.active else { - return false; - }; - if app - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::active_tab) - .is_none() - { - return false; - } - - for info in surface.pane_infos { - let empty_uploaded = HashMap::new(); - if graphics.slots.iter().any(|((pane_id, layer_id), slot)| { - *pane_id == info.id - && slot.layer.as_ref().is_some_and(|layer| { - clipped_placement(&pane_graphics_host_placement( - info, - layer_id, - slot.host_image_id, - cell_size, - layer, - &empty_uploaded, - false, - )) - .is_some() - }) - }) { - return true; - } - - if let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - { - let scrollback_offset = runtime - .scroll_metrics() - .map(|m| m.offset_from_bottom as u32) - .unwrap_or(0); - for placement in runtime.kitty_image_placements_with_data_filter(|_| false) { - let host_placement = HostPlacement { - pane_id: info.id, - host_image_id: None, - area: info.inner_rect, - cell_size, - source_key: HostSourceKey::Terminal { - pane_id: info.id, - image_id: placement.image_id, - }, - placement, - scrollback_offset, - }; - if clipped_placement(&host_placement).is_some() { - return true; - } - } - } - } - false -} - -fn encode_terminal_graphics_update( - cache: &mut HostGraphicsCache, - placements: &[HostPlacement], - view_changed: bool, - transaction_budget: Option, -) -> EncodedGraphics { - if transaction_budget.is_some() { - cache.request_placement_replay(); - return encode_graphics_update_incremental( - cache, - placements, - &HashSet::new(), - transaction_budget, - true, - ); - } - - cache.reset_incremental_state(); - let mut bytes = Vec::new(); - encode_terminal_graphics_update_legacy(&mut bytes, placements, view_changed, cache); - EncodedGraphics { - bytes, - incomplete: false, - } -} - -fn encode_terminal_graphics_update_legacy( - bytes: &mut Vec, - placements: &[HostPlacement], - view_changed: bool, - cache: &mut HostGraphicsCache, -) { - let current_sources = placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::Terminal { .. })) - .map(|placement| placement.source_key.clone()) - .collect::>(); - cache - .sources - .retain(|source, _| current_sources.contains(source)); - - let mut current_placements = HashSet::new(); - for placement in placements { - let Some((clipped, format_code)) = clipped_placement(placement) else { - continue; - }; - let host_id = host_image_id(placement.pane_id, &placement.placement); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let placement_key = (host_id, placement_id); - current_placements.insert(placement_key); - - match cache.images.get(&host_id).copied() { - Some(existing) if existing == image_signature => {} - Some(_) => { - encode_delete_image(bytes, host_id); - cache.placements.retain(|(image_id, id), _| { - if *image_id == host_id { - current_placements.remove(&(*image_id, *id)); - false - } else { - true - } - }); - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); - } - None => { - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); - } - } - - release_superseded_terminal_image_legacy( - bytes, - cache, - &mut current_placements, - placement.source_key.clone(), - host_id, - ); - - match cache.placements.get_mut(&placement_key) { - Some(existing) if !view_changed && *existing == placement_signature => {} - Some(existing) => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - *existing = placement_signature; - } - None => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - cache.placements.insert(placement_key, placement_signature); - } - } - } - - let stale = cache - .placements - .keys() - .filter(|key| !current_placements.contains(key)) - .copied() - .collect::>(); - for (host_id, placement_id) in stale { - encode_delete_placement(bytes, host_id, placement_id); - cache.placements.remove(&(host_id, placement_id)); - } -} - -fn release_superseded_terminal_image_legacy( - bytes: &mut Vec, - cache: &mut HostGraphicsCache, - current_placements: &mut HashSet<(u32, u32)>, - source: HostSourceKey, - host_id: u32, -) { - let Some(previous) = cache.sources.insert(source, host_id) else { - return; - }; - if previous == host_id || cache.sources.values().any(|id| *id == previous) { - return; - } - encode_delete_image(bytes, previous); - cache.images.remove(&previous); - cache.placements.retain(|(image_id, placement_id), _| { - if *image_id == previous { - current_placements.remove(&(*image_id, *placement_id)); - false - } else { - true - } - }); -} - /// Whether appending `additional` bytes to the `current_len` bytes already /// assembled keeps the transaction inside the caller's budget. Without a /// budget the incremental path intentionally stays one transaction per call. @@ -523,6 +144,127 @@ fn coalesced_transaction_fits( current_len.saturating_add(additional) <= budget } +fn image_transaction_fits(placement: &HostPlacement, budget: Option) -> bool { + let Some(budget) = budget else { + return true; + }; + image_transfer_estimated_size(placement.placement.data_len) <= budget +} + +pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { + let encoded = data_len.div_ceil(3).saturating_mul(4); + let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; + encoded.saturating_add(command_overhead) +} + +fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { + ( + placement.source_key.clone(), + host_placement_id(&placement.source_key, &placement.placement), + ) +} + +fn source_order(source: &HostSourceKey) -> (u32, String) { + match source { + HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), + HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), + HostSourceKey::ClientSurface { scope, source } => { + let mut hasher = DefaultHasher::new(); + scope.hash(&mut hasher); + source.hash(&mut hasher); + (hasher.finish() as u32, format!("{source:?}")) + } + } +} + +fn encode_placement_update( + cache: &mut HostGraphicsCache, + placement: &HostPlacement, +) -> Option> { + let (clipped, format_code) = clipped_placement(placement)?; + let host_id = placement + .host_image_id + .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); + let placement_id = host_placement_id(&placement.source_key, &placement.placement); + let key = (host_id, placement_id); + let image_signature = image_signature(placement, format_code); + let placement_signature = + placement_signature(clipped, placement.placement.z, placement.scrollback_offset); + let image_current = cache.images.get(&host_id) == Some(&image_signature); + let placement_current = cache.placements.get(&key) == Some(&placement_signature) + && (!cache.replay_placements || cache.replayed_placements.contains(&key)); + if image_current + && placement_current + && cache.sources.get(&placement.source_key) == Some(&host_id) + { + return None; + } + + let mut bytes = Vec::new(); + let mut displayed = false; + if !image_current { + if cache.images.contains_key(&host_id) + && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) + { + if !encode_transmit_and_display( + &mut bytes, + placement, + clipped, + format_code, + host_id, + placement_id, + ) { + return None; + } + displayed = true; + } else { + if cache.images.contains_key(&host_id) { + encode_delete_image(&mut bytes, host_id); + cache.placements.retain(|(id, _), _| *id != host_id); + cache.replayed_placements.retain(|(id, _)| *id != host_id); + } + if !encode_upload_image(&mut bytes, placement, format_code, host_id) { + return None; + } + } + cache.images.insert(host_id, image_signature); + } + + release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); + if !displayed && !placement_current { + encode_display_placement( + &mut bytes, + clipped, + host_id, + placement_id, + placement.placement.z, + ); + } + cache.placements.insert(key, placement_signature); + if cache.replay_placements { + cache.replayed_placements.insert(key); + } + Some(bytes) +} + +fn release_superseded_source_image( + bytes: &mut Vec, + cache: &mut HostGraphicsCache, + source: HostSourceKey, + host_id: u32, +) { + let Some(previous) = cache.sources.insert(source, host_id) else { + return; + }; + if previous == host_id || cache.sources.values().any(|id| *id == previous) { + return; + } + encode_delete_image(bytes, previous); + cache.images.remove(&previous); + cache.placements.retain(|(id, _), _| *id != previous); + cache.replayed_placements.retain(|(id, _)| *id != previous); +} + fn encode_graphics_update_incremental( cache: &mut HostGraphicsCache, placements: &[HostPlacement], @@ -699,118 +441,6 @@ fn encode_graphics_update_incremental( } } -fn image_transaction_fits(placement: &HostPlacement, budget: Option) -> bool { - let Some(budget) = budget else { - return true; - }; - let data = placement.placement.data_len; - let encoded = data.div_ceil(3).saturating_mul(4); - let command_overhead = data.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; - encoded.saturating_add(command_overhead) <= budget -} - -fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { - ( - placement.source_key.clone(), - host_placement_id(&placement.source_key, &placement.placement), - ) -} - -fn source_order(source: &HostSourceKey) -> (u32, String) { - match source { - HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), - HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), - } -} - -fn encode_placement_update( - cache: &mut HostGraphicsCache, - placement: &HostPlacement, -) -> Option> { - let (clipped, format_code) = clipped_placement(placement)?; - let host_id = placement - .host_image_id - .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let key = (host_id, placement_id); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let image_current = cache.images.get(&host_id) == Some(&image_signature); - let placement_current = cache.placements.get(&key) == Some(&placement_signature) - && (!cache.replay_placements || cache.replayed_placements.contains(&key)); - if image_current - && placement_current - && cache.sources.get(&placement.source_key) == Some(&host_id) - { - return None; - } - - let mut bytes = Vec::new(); - let mut displayed = false; - if !image_current { - if cache.images.contains_key(&host_id) - && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) - { - if !encode_transmit_and_display( - &mut bytes, - placement, - clipped, - format_code, - host_id, - placement_id, - ) { - return None; - } - displayed = true; - } else { - if cache.images.contains_key(&host_id) { - encode_delete_image(&mut bytes, host_id); - cache.placements.retain(|(id, _), _| *id != host_id); - cache.replayed_placements.retain(|(id, _)| *id != host_id); - } - if !encode_upload_image(&mut bytes, placement, format_code, host_id) { - return None; - } - } - cache.images.insert(host_id, image_signature); - } - - release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); - if !displayed && !placement_current { - encode_display_placement( - &mut bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - } - cache.placements.insert(key, placement_signature); - if cache.replay_placements { - cache.replayed_placements.insert(key); - } - Some(bytes) -} - -fn release_superseded_source_image( - bytes: &mut Vec, - cache: &mut HostGraphicsCache, - source: HostSourceKey, - host_id: u32, -) { - let Some(previous) = cache.sources.insert(source, host_id) else { - return; - }; - if previous == host_id || cache.sources.values().any(|id| *id == previous) { - return; - } - encode_delete_image(bytes, previous); - cache.images.remove(&previous); - cache.placements.retain(|(id, _), _| *id != previous); - cache.replayed_placements.retain(|(id, _)| *id != previous); -} - #[cfg(test)] fn drain_graphics_updates( cache: &mut HostGraphicsCache, @@ -827,107 +457,13 @@ fn drain_graphics_updates( } } -#[cfg(test)] -fn encode_graphics_update( - bytes: &mut Vec, - placements: &[HostPlacement], - replay: bool, - images: &mut HashMap, - host_placements: &mut HashMap<(u32, u32), PlacementSignature>, - sources: &mut HashMap, -) { - let mut cache = HostGraphicsCache { - images: std::mem::take(images), - placements: std::mem::take(host_placements), - sources: std::mem::take(sources), - ..HostGraphicsCache::default() - }; - let mut live = cache - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::>(); - live.extend( - placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::PaneLayer { .. })) - .map(|placement| placement.source_key.clone()), - ); - if live.is_empty() { - encode_terminal_graphics_update_legacy(bytes, placements, replay, &mut cache); - } else { - if replay { - cache.request_placement_replay(); - } - bytes.extend(drain_graphics_updates(&mut cache, placements, &live)); - } - *images = cache.images; - *host_placements = cache.placements; - *sources = cache.sources; -} - -pub(crate) fn clear_all_host_graphics() -> io::Result<()> { - let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); - let mut bytes = Vec::new(); - if let Ok(mut cache) = cache.lock() { - bytes = cache.clear_bytes(); - } - if bytes.is_empty() { - return Ok(()); - } - let mut stdout = io::stdout().lock(); - stdout.write_all(&bytes)?; - stdout.flush() -} - impl HostGraphicsCache { - fn clear_pane_sources(&mut self) -> Vec { - let pane_sources = self - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::>(); - let mut removed_images = HashSet::new(); - for source in pane_sources { - if let Some(image_id) = self.sources.remove(&source) { - removed_images.insert(image_id); - } - self.oversized.remove(&source); - } - - let mut bytes = Vec::new(); - for image_id in removed_images { - if self.sources.values().any(|id| *id == image_id) { - continue; - } - encode_delete_image(&mut bytes, image_id); - self.images.remove(&image_id); - self.placements.retain(|(id, _), _| *id != image_id); - self.replayed_placements.retain(|(id, _)| *id != image_id); - } - self.reset_incremental_progress(); - bytes - } - - fn has_pane_sources(&self) -> bool { - self.sources - .keys() - .any(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - } - fn reset_incremental_progress(&mut self) { self.continuation = None; self.replay_placements = false; self.replayed_placements.clear(); } - fn reset_incremental_state(&mut self) { - self.oversized.clear(); - self.reset_incremental_progress(); - } - fn quarantine_oversized(&mut self, source: HostSourceKey, signature: ImageSignature) { if !self.oversized.contains_key(&source) && self.oversized.len() >= MAX_OVERSIZED_SOURCES { if let Some(evicted) = self.oversized.keys().next().cloned() { @@ -937,38 +473,6 @@ impl HostGraphicsCache { self.oversized.insert(source, signature); } - pub(crate) fn trust_pane_layer( - &mut self, - key: &crate::app::pane_graphics::Key, - host_id: u32, - layer: &crate::app::pane_graphics::Layer, - ) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.oversized.remove(&source); - self.sources.insert(source, host_id); - self.images - .insert(host_id, pane_layer_image_signature(layer)); - } - - pub(crate) fn forget_pane_layer(&mut self, key: &crate::app::pane_graphics::Key, host_id: u32) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.sources.remove(&source); - self.oversized.remove(&source); - self.images.remove(&host_id); - self.placements.retain(|(id, _), _| *id != host_id); - self.replayed_placements.retain(|(id, _)| *id != host_id); - } - - pub(crate) fn is_empty(&self) -> bool { - self.images.is_empty() && self.placements.is_empty() - } - pub(crate) fn request_placement_replay(&mut self) { if !self.replay_placements { self.replay_placements = true; @@ -976,30 +480,6 @@ impl HostGraphicsCache { } } - #[cfg(test)] - fn hide_except_live_pane_layers(&mut self, live: &HashSet) -> Vec { - drain_graphics_updates(self, &[], live) - } - - #[cfg(test)] - pub(crate) fn test_image_count(&self) -> usize { - self.images.len() - } - - #[cfg(test)] - pub(crate) fn test_mark_non_empty(&mut self) { - self.images.insert( - HOST_IMAGE_ID_BASE, - ImageSignature { - image_width: 1, - image_height: 1, - format_code: 32, - data_len: 4, - data_fingerprint: 1, - }, - ); - } - pub(crate) fn clear_bytes(&mut self) -> Vec { let mut bytes = Vec::new(); for id in self.images.keys().copied().collect::>() { @@ -1008,54 +488,10 @@ impl HostGraphicsCache { self.images.clear(); self.placements.clear(); self.sources.clear(); - self.reset_incremental_state(); - self.view = None; + self.oversized.clear(); + self.reset_incremental_progress(); bytes } - - pub(crate) fn clear_next(&mut self) -> EncodedGraphics { - self.continuation = None; - let mut bytes = Vec::new(); - if let Some(id) = self.images.keys().copied().min() { - encode_delete_image(&mut bytes, id); - self.images.remove(&id); - self.placements.retain(|(image, _), _| *image != id); - self.sources.retain(|_, image| *image != id); - self.replayed_placements.retain(|(image, _)| *image != id); - } else if let Some(key) = self.placements.keys().copied().min() { - encode_delete_placement(&mut bytes, key.0, key.1); - self.placements.remove(&key); - self.replayed_placements.remove(&key); - } else { - self.sources.clear(); - self.oversized.clear(); - self.view = None; - self.replay_placements = false; - self.replayed_placements.clear(); - } - EncodedGraphics { - bytes, - incomplete: !self.is_empty(), - } - } - - fn update_view(&mut self, view_key: Option) -> bool { - if self.view == view_key { - return false; - } - self.view = view_key; - self.continuation = None; - true - } -} - -fn active_view_key(app: &AppState) -> Option { - let ws_idx = app.active?; - let ws = app.workspaces.get(ws_idx)?; - Some(HostViewKey { - workspace_index: ws_idx, - tab_index: ws.active_tab_index(), - }) } fn collect_visible_placements( @@ -1284,6 +720,11 @@ fn host_placement_id(source_key: &HostSourceKey, placement: &KittyImagePlacement pane_id.raw().hash(&mut hasher); layer_id.hash(&mut hasher); } + HostSourceKey::ClientSurface { scope, source } => { + "client.surface".hash(&mut hasher); + scope.hash(&mut hasher); + source.hash(&mut hasher); + } } placement.image_id.hash(&mut hasher); placement.placement_id.hash(&mut hasher); @@ -1295,86 +736,6 @@ pub(crate) struct DirectFileCommand { pub(crate) control: String, } -pub(crate) fn prepare_direct_file( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, - allow_placement: bool, - cache: &HostGraphicsCache, - key: &crate::app::pane_graphics::Key, -) -> Option { - let slot = graphics.slots.get(key)?; - let layer = slot.layer.as_ref()?; - layer.direct_lease()?; - - let info = allow_placement - .then(|| surface.pane_infos.iter().find(|info| info.id == key.0)) - .flatten() - .filter(|_| app.mode == Mode::Terminal && cell_size.is_known() && app.active.is_some()); - if let Some(command) = info - .map(|info| { - pane_graphics_host_placement( - info, - &key.1, - slot.host_image_id, - cell_size, - layer, - &cache.images, - false, - ) - }) - .and_then(|placement| direct_file_command(&placement, slot.host_image_id)) - .map(|(command, _, _, _)| command) - { - return Some(command); - } - - let inline_fallback_available = layer.data_len() - <= crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES - && graphics.can_store_inline(key, layer.data_len()); - (!inline_fallback_available).then(|| direct_file_upload_command(layer, slot.host_image_id)) -} - -fn direct_file_upload_command( - layer: &crate::app::pane_graphics::Layer, - host_image_id: u32, -) -> DirectFileCommand { - DirectFileCommand { - leading: Vec::new(), - control: format!( - "a=t,f=32,s={},v={},i={host_image_id},q=0", - layer.image_width, layer.image_height - ), - } -} - -fn direct_file_command( - placement: &HostPlacement, - host_image_id: u32, -) -> Option<(DirectFileCommand, ClippedPlacement, u32, u32)> { - let (clipped, format_code) = clipped_placement(placement)?; - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let mut control = format!( - "a=T,f={format_code},s={},v={},i={host_image_id},p={placement_id},c={},r={},z={},C=1,q=0", - placement.placement.image_width, - placement.placement.image_height, - clipped.cols, - clipped.rows, - placement.placement.z, - ); - append_placement_controls(&mut control, clipped); - Some(( - DirectFileCommand { - leading: format!("\x1b[{};{}H", clipped.y + 1, clipped.x + 1).into_bytes(), - control, - }, - clipped, - format_code, - placement_id, - )) -} - #[cfg(unix)] pub(crate) fn encode_kitty_regular_file( out: &mut Vec, @@ -1710,18 +1071,6 @@ fn encode_kitty_data(out: &mut Vec, control: &str, data: &[u8]) { mod tests { use super::*; - #[test] - fn fallback_cell_size_is_usable_only_for_nonempty_areas() { - assert_eq!( - HostCellSize::fallback_for_area(Rect::new(0, 0, 80, 24)), - HostCellSize { - width_px: 8, - height_px: 16, - } - ); - assert!(!HostCellSize::fallback_for_area(Rect::default()).is_known()); - } - fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement { HostPlacement { pane_id: PaneId::from_raw(1), @@ -1779,59 +1128,19 @@ mod tests { replay: bool, ) -> Vec { let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - placements, - replay, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); + if replay { + cache.request_placement_replay(); + } + let live = cache + .sources + .keys() + .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) + .cloned() + .collect::>(); + bytes.extend(drain_graphics_updates(cache, placements, &live)); bytes } - #[test] - fn terminal_graphics_without_pane_layers_preserves_legacy_transcript() { - fn record(transcript: &mut Vec, bytes: &[u8]) { - transcript.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - transcript.extend_from_slice(bytes); - } - - fn fnv1a(bytes: &[u8]) -> u64 { - bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { - (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) - }) - } - - let mut cache = HostGraphicsCache::default(); - let mut transcript = Vec::new(); - - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], true), - ); - - let mut changed = test_placement(-1, 2); - changed.placement.data_fingerprint = 43; - record(&mut transcript, &update(&mut cache, &[changed], false)); - record(&mut transcript, &update(&mut cache, &[], false)); - - assert_eq!(transcript.len(), 10_084); - assert_eq!(fnv1a(&transcript), 0xc5bd_83e4_b039_870e); - } - #[test] fn terminal_placement_id_preserves_legacy_identity() { let placement = test_placement(0, 0); @@ -1873,19 +1182,6 @@ mod tests { assert!(text.ends_with("\x1b\\\x1b8")); } - #[test] - fn direct_file_uses_one_clipped_transmit_and_display_at_the_final_position() { - let mut placement = pane_layer_placement(-1, 2); - placement.area = Rect::new(10, 4, 8, 6); - let (command, _, _, _) = direct_file_command(&placement, (1 << 31) | 9).unwrap(); - let control = command.control; - - assert_eq!(command.leading, b"\x1b[7;11H"); - assert!(control.starts_with("a=T,f=32,s=30,v=30,i=2147483657,p=")); - assert!(control.contains(",c=2,r=3,z=0,C=1,q=0,x=10,w=20,h=30")); - assert!(direct_file_command(&pane_layer_placement(30, 0), 9).is_none()); - } - #[test] fn pane_graphics_image_ids_are_disjoint_from_terminal_image_ids() { let placement = test_placement(0, 0); @@ -2033,530 +1329,6 @@ mod tests { assert!(String::from_utf8_lossy(&bytes).contains("a=p")); } - #[test] - fn empty_image_data_does_not_mark_image_uploaded() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut placement = test_placement(0, 0); - placement.placement.data.clear(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - assert!(bytes.is_empty()); - assert!(images.is_empty()); - assert!(placements.is_empty()); - } - - #[test] - fn same_image_signature_reuses_host_upload_across_source_image_ids() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - - bytes.clear(); - let mut same_image_new_source_id = test_placement(0, 0); - same_image_new_source_id.placement.image_id = 8; - same_image_new_source_id.placement.placement_id = 4; - same_image_new_source_id.placement.data.clear(); - encode_graphics_update( - &mut bytes, - &[same_image_new_source_id], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let reused = String::from_utf8_lossy(&bytes); - assert!(!reused.contains("a=t")); - assert!(reused.contains("a=p")); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn pane_layer_replacement_is_atomic_without_delete_to_blank() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut first = pane_layer_placement(0, 0); - first.host_image_id = Some((1 << 31) | 7); - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - - bytes.clear(); - let mut changed = pane_layer_placement(0, 0); - changed.host_image_id = Some((1 << 31) | 7); - changed.placement.data_fingerprint += 1; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=T,t=d")); - assert!(update.contains(",p=") && update.contains(",C=1,q=2")); - assert!(!update.contains("a=d")); - } - - #[test] - fn replaced_image_content_deletes_superseded_host_image() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - let superseded_host_id = *images.keys().next().expect("uploaded host image"); - - // Same source image id, new pixel content: the fresh content maps to - // a fresh host image id, so the replaced one must be deleted. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=t"), "changed content re-uploads"); - assert!( - update.contains(&format!("a=d,d=I,i={superseded_host_id}")), - "superseded host image is deleted" - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn shared_host_image_survives_while_another_source_references_it() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1, "same content dedups to one host image"); - - // One source moves to new content while the other still shows the - // old image: the shared host image must survive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed, twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I"), "shared host image survives"); - assert_eq!(images.len(), 2); - } - - #[test] - fn stale_source_entry_does_not_block_superseded_image_delete() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 2); - let shared_host_id = *images.keys().next().expect("uploaded host image"); - - // The twin source is gone and the survivor changed content: the - // vanished source's stale entry must not keep the old host image - // alive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!( - update.contains(&format!("a=d,d=I,i={shared_host_id}")), - "old host image is deleted once its last live source moves on" - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn stale_placement_deletes_placement_not_image_immediately() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(placements.len(), 1); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[], - false, - &mut images, - &mut placements, - &mut sources, - ); - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains("a=d,d=i")); - assert!(!delete.contains("d=I")); - assert!(placements.is_empty()); - assert_eq!(images.len(), 1); - } - - #[test] - fn trusted_direct_image_uses_reserved_id_for_placement_without_upload() { - let key = (PaneId::from_raw(1), "primary".to_owned()); - let layer = crate::app::pane_graphics::Layer::inline( - crate::api::schema::PaneGraphicsFormat::Rgba, - 30, - 30, - vec![255; 30 * 30 * 4], - Default::default(), - 0, - ); - let reserved_id = (1 << 31) | 77; - let mut cache = HostGraphicsCache::default(); - cache.trust_pane_layer(&key, reserved_id, &layer); - let mut placement = pane_layer_placement(0, 0); - placement.host_image_id = Some(reserved_id); - placement.placement.data.clear(); - placement.placement.data_len = layer.data_len(); - placement.placement.data_fingerprint = layer.data_fingerprint; - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=p,i={reserved_id}"))); - assert!(!update.contains("a=t")); - - let live = HashSet::from([HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }]); - let hidden = String::from_utf8(cache.hide_except_live_pane_layers(&live)).unwrap(); - assert!(hidden.contains("a=d,d=i")); - assert!(!hidden.contains("a=d,d=I")); - assert!(cache.images.contains_key(&reserved_id)); - assert!(cache.placements.is_empty()); - - let mut returning = pane_layer_placement(0, 0); - returning.host_image_id = Some(reserved_id); - returning.placement.data.clear(); - returning.placement.data_len = layer.data_len(); - returning.placement.data_fingerprint = layer.data_fingerprint; - let mut replay = Vec::new(); - encode_graphics_update( - &mut replay, - &[returning], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let replay = String::from_utf8(replay).unwrap(); - assert!(replay.contains(&format!("a=p,i={reserved_id}"))); - assert!(!replay.contains("a=t")); - - cache.forget_pane_layer(&key, reserved_id); - let mut fallback = pane_layer_placement(0, 0); - fallback.host_image_id = Some(reserved_id); - let mut retransmit = Vec::new(); - encode_graphics_update( - &mut retransmit, - &[fallback], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert!(String::from_utf8_lossy(&retransmit).contains("a=t")); - } - - #[test] - fn hidden_layer_and_full_redraw_replay_placement_without_pixels() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = pane_layer_placement(0, 0); - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - for (visible, replay) in [(false, false), (true, false), (true, true)] { - bytes.clear(); - let current = visible.then(|| pane_layer_placement(0, 0)); - encode_graphics_update( - &mut bytes, - current.as_slice(), - replay, - &mut images, - &mut placements, - &mut sources, - ); - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=t")); - assert!(!update.contains("a=d,d=I")); - assert_eq!(update.contains("a=p"), visible); - } - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_deletes_unreferenced_host_image() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let host_id = *cache.images.keys().next().expect("uploaded pane layer"); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains(&format!("a=d,d=I,i={host_id}"))); - assert!(cache.images.is_empty()); - assert!(cache.placements.is_empty()); - assert!(cache.sources.is_empty()); - } - - #[test] - fn hidden_pane_layer_retains_image_and_removes_only_placement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let host_id = *images.keys().next().expect("uploaded pane layer"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=d,d=i")); - assert!(!update.contains(&format!("a=d,d=I,i={host_id}"))); - assert_eq!(images.len(), 1); - assert!(placements.is_empty()); - assert_eq!(sources.len(), 1); - } - - #[test] - fn clipped_terminal_source_retains_identity_for_later_content_replacement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let original_host_id = *images.keys().next().expect("uploaded terminal image"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[test_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=d,d=I,i={original_host_id}"))); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_preserves_image_shared_with_terminal_source() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0), test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert_eq!(cache.images.len(), 1); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - encode_graphics_update( - &mut bytes, - &[test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I")); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), 1); - assert_eq!(cache.sources.len(), 1); - } - #[test] fn changing_first_source_does_not_starve_second_source() { let layers = |first| { @@ -2647,482 +1419,6 @@ mod tests { } } - #[test] - fn terminal_only_headless_budget_does_not_let_large_image_starve_small_image() { - let mut large = test_placement(0, 0); - large.placement.data_len = 24 * 1024 * 1024; - let mut small = test_placement(4, 0); - small.placement.image_id = 8; - small.source_key = HostSourceKey::Terminal { - pane_id: small.pane_id, - image_id: 8, - }; - let small_source = small.source_key.clone(); - let mut cache = HostGraphicsCache::default(); - - let large_only = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&large), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(large_only.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - assert!(cache.images.is_empty()); - - let hidden = encode_terminal_graphics_update( - &mut cache, - &[], - true, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(hidden.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - - let with_small = encode_terminal_graphics_update( - &mut cache, - &[large, small], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&with_small.bytes).contains("a=t")); - assert!(with_small.bytes.len() <= HEADLESS_GRAPHICS_TRANSACTION_BUDGET); - assert!(!with_small.incomplete); - assert_eq!(cache.oversized.len(), 1); - assert_eq!(cache.images.len(), 1); - assert!(cache.sources.contains_key(&small_source)); - } - - /// A Unicode-placeholder image reaches `encode_terminal_graphics_update` as one - /// placement per viewport row, because `kitty_virtual_image_placements` scans the - /// viewport row by row. Build one image that covers `rows` rows that way. - fn image_covering_rows(rows: usize) -> Vec { - let cell_height = 10u32; - let image_height = (rows as u32) * cell_height; - let image_width = 30u32; - let data_len = (image_width * image_height * 4) as usize; - (0..rows) - .map(|row| { - let mut placement = test_placement(0, row as i32); - placement.area = Rect::new(0, 0, 120, 60); - placement.placement.placement_id = 100 + row as u32; - placement.placement.image_width = image_width; - placement.placement.image_height = image_height; - placement.placement.data_len = data_len; - placement.placement.data = vec![255; data_len]; - placement.placement.render.grid_rows = 1; - placement.placement.render.source_y = (row as u32) * cell_height; - placement.placement.render.source_height = cell_height; - placement.placement.render.source_width = image_width; - placement - }) - .collect() - } - - #[test] - fn budgeted_image_rows_upload_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - let upload = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!upload.incomplete); - let upload = String::from_utf8(upload.bytes).unwrap(); - assert_eq!(upload.matches("a=t").count(), 1); - assert_eq!(upload.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1, "one image backs every row"); - assert_eq!(cache.placements.len(), IMAGE_ROWS, "one placement per row"); - } - - #[test] - fn budgeted_image_rows_disappear_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let removed = encode_terminal_graphics_update( - &mut cache, - &[], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!removed.incomplete); - let removed = String::from_utf8(removed.bytes).unwrap(); - assert_eq!(removed.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(cache.placements.is_empty()); - } - - #[test] - fn budgeted_image_rows_delete_old_rows_together_before_replacement() { - const IMAGE_ROWS: usize = 23; - let old = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &old, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let mut replacement = image_covering_rows(IMAGE_ROWS); - for placement in &mut replacement { - placement.placement.data_fingerprint += 1; - } - let cleanup = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert_eq!(cleanup.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(!cleanup.contains("a=t")); - - let replaced = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replaced.incomplete); - let replaced = String::from_utf8(replaced.bytes).unwrap(); - assert_eq!(replaced.matches("a=t").count(), 1); - assert_eq!(replaced.matches("a=d,d=I").count(), 1); - assert_eq!(replaced.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), IMAGE_ROWS); - } - - #[test] - fn budgeted_image_rows_redisplay_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), IMAGE_ROWS); - } - - #[test] - fn budgeted_pixel_uploads_do_not_coalesce() { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - let placements = [first, second]; - let mut cache = HostGraphicsCache::default(); - - let first_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!( - first_pass.incomplete, - "the second upload must wait for its own transaction" - ); - let first_pass = String::from_utf8(first_pass.bytes).unwrap(); - assert_eq!(first_pass.matches("a=t").count(), 1); - - let second_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!second_pass.incomplete); - let second_pass = String::from_utf8(second_pass.bytes).unwrap(); - assert_eq!(second_pass.matches("a=t").count(), 1); - assert_eq!(cache.images.len(), 2); - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), 2); - } - - #[test] - fn budgeted_redisplay_coalescing_respects_budget() { - let placements = image_covering_rows(2); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A budget that admits one re-display command but not two. - let tight = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(tight.incomplete); - let tight = String::from_utf8(tight.bytes).unwrap(); - assert!(!tight.contains("a=t")); - assert_eq!(tight.matches("a=p").count(), 1); - - let rest = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(!rest.incomplete); - let rest = String::from_utf8(rest.bytes).unwrap(); - assert!(!rest.contains("a=t")); - assert_eq!(rest.matches("a=p").count(), 1); - } - - #[test] - fn budgeted_upload_keeps_other_redisplays_out() { - let fresh = test_placement(0, 0); - let mut cached = test_placement(4, 0); - cached.placement.image_id = 8; - cached.placement.placement_id = 4; - cached.placement.data_fingerprint = 43; - cached.source_key = HostSourceKey::Terminal { - pane_id: cached.pane_id, - image_id: 8, - }; - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&cached), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A fresh image followed by the cached image at a new position. - cached.placement.render.viewport_col = 8; - let placements = [fresh, cached]; - let mut passes = 0; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 4, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=t") { - assert_eq!( - bytes.matches("a=p").count(), - 1, - "an upload carries only its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - } - - #[test] - fn budgeted_superseded_image_delete_does_not_coalesce() { - fn pair() -> [HostPlacement; 2] { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - [first, second] - } - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &pair(), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - - // The first source now shows the second image's content, so its old - // image gets released, while the second placement moves. - let [mut first, mut second] = pair(); - first.placement.data_fingerprint = 43; - second.placement.render.viewport_col = 8; - let placements = [first, second]; - let mut passes = 0; - let mut saw_release = false; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 6, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=d,d=I") { - saw_release = true; - assert!( - bytes.matches("a=p").count() <= 1, - "a superseded-image delete carries at most its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert!(saw_release, "the old image was released"); - assert_eq!(cache.images.len(), 1); - } - - #[test] - fn budgeted_pane_cleanup_precedes_terminal_image_upload() { - let mut cache = HostGraphicsCache::default(); - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - cache.sources.insert(pane_source, 99); - cache.images.insert( - 99, - ImageSignature { - image_width: 30, - image_height: 30, - format_code: 32, - data_len: 30 * 30 * 4, - data_fingerprint: 9, - }, - ); - let terminal = test_placement(0, 0); - - let cleanup = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&terminal), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert!(cleanup.contains("a=d,d=I,i=99")); - assert!(!cleanup.contains("a=t")); - assert!(cache.images.is_empty()); - - let upload = encode_terminal_graphics_update( - &mut cache, - &[terminal], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&upload.bytes).contains("a=t")); - } - - #[test] - fn terminal_only_high_level_path_preserves_budget_quarantine() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = crate::workspace::Workspace::test_new("graphics-budget-dispatch"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - crate::ui::compute_view(&mut app, Rect::new(0, 0, 80, 24)); - - let source = HostSourceKey::Terminal { - pane_id, - image_id: 7, - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized( - source.clone(), - ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }, - ); - - let encoded = encode_local_pane_graphics( - &app, - &crate::app::pane_graphics::Runtime::default(), - &TerminalRuntimeRegistry::new(), - app.view.tab_surface(), - HostCellSize { - width_px: 10, - height_px: 20, - }, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut cache, - ); - - assert!(encoded.bytes.is_empty()); - assert!(cache.oversized.contains_key(&source)); - } - #[test] fn terminal_image_data_requests_deduplicate_and_reconsider_changed_signatures() { let pane_id = PaneId::from_raw(1); @@ -3178,49 +1474,6 @@ mod tests { )); } - #[test] - fn terminal_quarantine_survives_pane_layer_cleanup_and_stays_bounded() { - let terminal_source = HostSourceKey::Terminal { - pane_id: PaneId::from_raw(1), - image_id: 7, - }; - let signature = ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }; - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized(terminal_source.clone(), signature); - cache.sources.insert(pane_source, 99); - cache.images.insert(99, signature); - - let cleared = String::from_utf8(cache.clear_pane_sources()).unwrap(); - assert!(cleared.contains("a=d,d=I,i=99")); - assert!(cache.oversized.contains_key(&terminal_source)); - - for image_id in 0..=MAX_OVERSIZED_SOURCES as u32 { - cache.quarantine_oversized( - HostSourceKey::Terminal { - pane_id: PaneId::from_raw(2), - image_id, - }, - ImageSignature { - data_fingerprint: u64::from(image_id), - ..signature - }, - ); - } - assert_eq!(cache.oversized.len(), MAX_OVERSIZED_SOURCES); - cache.clear_bytes(); - assert!(cache.oversized.is_empty()); - } - #[test] fn maximum_pane_graphics_stream_payload_fits_client_graphics_frame() { let mut placement = pane_layer_placement(0, 0); diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs new file mode 100644 index 00000000..4cd55a75 --- /dev/null +++ b/src/kitty_graphics/surface.rs @@ -0,0 +1,1067 @@ +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; + +use ratatui::layout::Rect; + +use super::{ + clipped_placement, collect_visible_placements, encode_graphics_update_incremental, + HostCellSize, HostGraphicsCache, HostPlacement, HostSourceKey, ImageSignature, +}; +use crate::ghostty::{ + KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, +}; +use crate::layout::PaneId; +use crate::protocol::{ + SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement, + SurfaceGraphicsScene, SurfaceGraphicsSource, SurfaceGraphicsTarget, +}; + +const MAX_SURFACE_GRAPHICS_PLACEMENTS: usize = 4_096; + +#[derive(Clone, Debug, Default)] +pub(crate) struct DeliveryCache { + assets: HashSet, + pending: bool, +} + +impl DeliveryCache { + pub(crate) fn has_pending(&self) -> bool { + self.pending + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Visibility { + Main, + Popup, + Hidden, +} + +#[derive(Debug, Default)] +pub(crate) struct ClientState { + scope: String, + scene: SurfaceGraphicsScene, + assets: HashMap>, + host: HostGraphicsCache, + trusted_direct: HashMap, + reset_pending: bool, + stale_images: Vec, + forced_delete_images: Vec, +} + +impl ClientState { + pub(crate) fn set_scope(&mut self, scope: &str) { + if self.scope == scope { + return; + } + self.scope = scope.to_owned(); + self.scene = SurfaceGraphicsScene::default(); + self.assets.clear(); + self.trusted_direct.clear(); + self.stale_images.clear(); + self.forced_delete_images.clear(); + self.reset_pending = true; + } + + #[cfg(unix)] + pub(crate) fn trust_direct_asset( + &mut self, + key: &SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + if image_id != host_image_id(&self.scope, key) { + return false; + } + self.host + .images + .insert(image_id, image_signature_from_asset(key)); + if self + .scene + .placements + .iter() + .all(|placement| &placement.asset != key) + && !self.scene.retained_assets.contains(key) + { + self.trusted_direct.insert(key.clone(), image_id); + } + true + } + + #[cfg(unix)] + pub(crate) fn retire_direct_image(&mut self, image_id: u32) { + self.trusted_direct + .retain(|_, trusted| *trusted != image_id); + self.forced_delete_images.push(image_id); + } + + pub(crate) fn take_pending_cleanup(&mut self) -> Vec { + let mut bytes = if self.reset_pending { + self.reset_pending = false; + self.stale_images.clear(); + self.host.clear_bytes() + } else { + Vec::new() + }; + self.forced_delete_images.sort_unstable(); + self.forced_delete_images.dedup(); + for image_id in self.forced_delete_images.drain(..) { + self.host.images.remove(&image_id); + self.host.placements.retain(|(id, _), _| *id != image_id); + self.host.sources.retain(|_, id| *id != image_id); + self.host + .replayed_placements + .retain(|(id, _)| *id != image_id); + super::encode_delete_image(&mut bytes, image_id); + } + bytes + } + + pub(crate) fn set_scene(&mut self, mut scene: SurfaceGraphicsScene) { + let desired = scene + .placements + .iter() + .map(|placement| placement.asset.clone()) + .chain(scene.retained_assets.iter().cloned()) + .collect::>(); + let previous = self + .scene + .placements + .iter() + .map(|placement| placement.asset.clone()) + .chain(self.scene.retained_assets.iter().cloned()) + .collect::>(); + self.stale_images.extend( + previous + .difference(&desired) + .map(|key| host_image_id(&self.scope, key)), + ); + let unclaimed = self + .trusted_direct + .iter() + .filter_map(|(key, image_id)| (!desired.contains(key)).then_some(*image_id)) + .collect::>(); + self.stale_images.extend(unclaimed); + self.trusted_direct.clear(); + let placed = scene + .placements + .iter() + .map(|placement| placement.asset.clone()) + .collect::>(); + self.assets.retain(|key, _| placed.contains(key)); + for asset in std::mem::take(&mut scene.assets) { + if asset.data.len() as u64 == asset.key.data_len && placed.contains(&asset.key) { + self.assets.insert(asset.key, asset.data); + } + } + self.scene = scene; + } + + pub(crate) fn encode( + &mut self, + visibility: Visibility, + main_origin: (u16, u16), + popup_origin: Option<(u16, u16)>, + cell_size: HostCellSize, + ) -> Vec { + let mut bytes = self.take_pending_cleanup(); + self.stale_images.sort_unstable(); + self.stale_images.dedup(); + for image_id in self.stale_images.drain(..) { + if self.host.images.remove(&image_id).is_some() { + super::encode_delete_image(&mut bytes, image_id); + } + self.host.placements.retain(|(id, _), _| *id != image_id); + self.host.sources.retain(|_, id| *id != image_id); + self.host + .replayed_placements + .retain(|(id, _)| *id != image_id); + } + if !cell_size.is_known() || self.scope.is_empty() { + bytes.extend(self.host.clear_bytes()); + return bytes; + } + + let placements = self + .scene + .placements + .iter() + .filter_map(|placement| { + client_host_placement( + &self.scope, + placement, + self.assets.get(&placement.asset).map(Vec::as_slice), + visibility, + main_origin, + popup_origin, + cell_size, + ) + }) + .collect::>(); + self.host.request_placement_replay(); + loop { + let encoded = encode_graphics_update_incremental( + &mut self.host, + &placements, + &HashSet::new(), + None, + false, + ); + bytes.extend(encoded.bytes); + if !encoded.incomplete { + return bytes; + } + } + } +} + +pub(crate) fn pane_layer_asset_key( + app: &crate::app::App, + key: &crate::app::pane_graphics::Key, + layer: &crate::app::pane_graphics::Layer, +) -> Option { + let workspace_index = app + .state + .workspaces + .iter() + .position(|workspace| workspace.pane_state(key.0).is_some())?; + Some(SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::PaneLayer { + pane_id: app.public_pane_id(workspace_index, key.0)?, + layer_id: key.1.clone(), + }, + image_width: layer.image_width, + image_height: layer.image_height, + format: match layer.format { + crate::api::schema::PaneGraphicsFormat::Rgb => SurfaceGraphicsFormat::Rgb, + crate::api::schema::PaneGraphicsFormat::Rgba + | crate::api::schema::PaneGraphicsFormat::Bgra => SurfaceGraphicsFormat::Rgba, + crate::api::schema::PaneGraphicsFormat::Png => SurfaceGraphicsFormat::Png, + }, + data_len: layer.data_len() as u64, + data_fingerprint: layer.data_fingerprint, + }) +} + +pub(crate) fn host_image_id(scope: &str, key: &SurfaceGraphicsAssetKey) -> u32 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + scope.hash(&mut hasher); + key.hash(&mut hasher); + 10_000 + ((hasher.finish() as u32) % 900_000) +} + +pub(crate) fn direct_upload_control(scope: &str, key: &SurfaceGraphicsAssetKey) -> (u32, String) { + let image_id = host_image_id(scope, key); + ( + image_id, + format!( + "a=t,f={},s={},v={},i={image_id},q=0", + format_code(key.format), + key.image_width, + key.image_height + ), + ) +} + +pub(crate) fn collect_scene( + app: &crate::app::App, + surface: crate::ui::TabSurfaceView<'_>, + popup_content_size: Option<(u16, u16)>, + cell_size: HostCellSize, + delivered: &DeliveryCache, + client_id: u64, +) -> (SurfaceGraphicsScene, DeliveryCache) { + if !cell_size.is_known() { + return (SurfaceGraphicsScene::default(), DeliveryCache::default()); + } + let workspace_index = app.state.active; + let mut targets = HashMap::new(); + let mut public_panes = HashMap::new(); + if let Some(workspace_index) = workspace_index { + for pane in surface.pane_infos { + if let Some(public_id) = app.public_pane_id(workspace_index, pane.id) { + public_panes.insert(public_id.clone(), pane.id); + targets.insert(pane.id, SurfaceGraphicsTarget::Pane { pane_id: public_id }); + } + } + } + let popup_target = app.state.popup_pane.as_ref().map(|popup| { + ( + popup.pane_id, + SurfaceGraphicsTarget::Popup { + terminal_id: popup.terminal_id.to_string(), + }, + ) + }); + if let Some((pane_id, target)) = popup_target.as_ref() { + targets.insert(*pane_id, target.clone()); + } + + // Reconstruct only the small image-signature index expected by the existing + // collector. This prevents copying already-delivered image payloads on each + // pane-scaled render while keeping Ghostty as the authoritative image store. + let mut uploaded_images = HashMap::new(); + let mut delivered_terminal_images = HashMap::new(); + for key in &delivered.assets { + let signature = image_signature_from_asset(key); + match &key.source { + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { pane_id }, + image_id, + } => { + if let Some(pane_id) = public_panes.get(pane_id) { + delivered_terminal_images.insert( + HostSourceKey::Terminal { + pane_id: *pane_id, + image_id: *image_id, + }, + signature, + ); + } + } + SurfaceGraphicsSource::PaneLayer { pane_id, layer_id } => { + if let Some(pane_id) = public_panes.get(pane_id) { + if let Some(slot) = app.pane_graphics.slots.get(&(*pane_id, layer_id.clone())) { + uploaded_images.insert(slot.host_image_id, signature); + } + } + } + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Popup { .. }, + .. + } => {} + } + } + let mut host_placements = collect_visible_placements( + &app.state, + &app.pane_graphics, + &app.terminal_runtimes, + surface, + cell_size, + &uploaded_images, + &delivered_terminal_images, + ); + + if let (Some(popup), Some((width, height)), Some((_, target))) = ( + app.state.popup_pane.as_ref(), + popup_content_size, + popup_target.as_ref(), + ) { + if let Some(runtime) = app.terminal_runtimes.get(&popup.terminal_id) { + let mut requested = HashSet::new(); + for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| { + let key = asset_key_from_descriptor( + SurfaceGraphicsSource::Terminal { + target: target.clone(), + image_id: descriptor.image_id, + }, + descriptor, + ); + !delivered.assets.contains(&key) && requested.insert(key) + }) { + host_placements.push(HostPlacement { + pane_id: popup.pane_id, + host_image_id: None, + area: Rect::new(0, 0, width, height), + cell_size, + source_key: HostSourceKey::Terminal { + pane_id: popup.pane_id, + image_id: placement.image_id, + }, + placement, + scrollback_offset: runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u32) + .unwrap_or(0), + }); + } + } + } + + let mut placements = Vec::new(); + let mut asset_data = HashMap::>::new(); + for mut placement in host_placements { + if placements.len() == MAX_SURFACE_GRAPHICS_PLACEMENTS { + break; + } + let Some(target) = targets.get(&placement.pane_id).cloned() else { + continue; + }; + let source = match &placement.source_key { + HostSourceKey::Terminal { image_id, .. } => SurfaceGraphicsSource::Terminal { + target, + image_id: *image_id, + }, + HostSourceKey::PaneLayer { layer_id, .. } => { + let SurfaceGraphicsTarget::Pane { pane_id } = target else { + continue; + }; + SurfaceGraphicsSource::PaneLayer { + pane_id, + layer_id: layer_id.clone(), + } + } + HostSourceKey::ClientSurface { .. } => continue, + }; + let Some((clipped, _)) = clipped_placement(&placement) else { + continue; + }; + let asset = asset_key(source, &placement.placement); + if !placement.placement.data.is_empty() { + asset_data + .entry(asset.clone()) + .or_insert_with(|| std::mem::take(&mut placement.placement.data)); + } + placements.push(SurfaceGraphicsPlacement { + asset, + logical_placement_id: placement.placement.placement_id, + x: clipped.x, + y: clipped.y, + cols: clipped.cols, + rows: clipped.rows, + source_x: clipped.source_x, + source_y: clipped.source_y, + source_width: clipped.source_width, + source_height: clipped.source_height, + x_offset: clipped.x_offset, + y_offset: clipped.y_offset, + z: placement.placement.z, + scrollback_offset: placement.scrollback_offset, + }); + } + + let desired = placements + .iter() + .map(|placement| placement.asset.clone()) + .collect::>(); + let mut next = DeliveryCache { + assets: delivered.assets.intersection(&desired).cloned().collect(), + pending: false, + }; + let mut assets = Vec::new(); + let mut available = asset_data.into_iter().collect::>(); + available.sort_by_key(|(key, _)| format!("{:?}", key.source)); + let mut payload_bytes = 0usize; + for (key, data) in available { + if next.assets.contains(&key) { + continue; + } + let encoded_size = super::image_transfer_estimated_size(data.len()); + if encoded_size > super::HEADLESS_GRAPHICS_TRANSACTION_BUDGET { + continue; + } + if payload_bytes.saturating_add(encoded_size) > super::HEADLESS_GRAPHICS_TRANSACTION_BUDGET + { + next.pending = true; + continue; + } + payload_bytes = payload_bytes.saturating_add(encoded_size); + assets.push(SurfaceGraphicsAsset { + key: key.clone(), + data, + }); + next.assets.insert(key); + } + assets.sort_by_key(|asset| format!("{:?}", asset.key.source)); + placements.sort_by_key(|placement| { + ( + format!("{:?}", placement.asset.source), + placement.logical_placement_id, + placement.y, + placement.x, + ) + }); + let mut retained_assets = app + .pane_graphics + .slots + .iter() + .filter_map(|(key, slot)| { + let layer = slot.layer.as_ref()?; + let retained_for_client = layer.resident_client() == Some(client_id) + || slot + .direct_gate + .as_ref() + .is_some_and(|gate| gate.client_id == client_id); + retained_for_client.then(|| pane_layer_asset_key(app, key, layer))? + }) + .collect::>(); + retained_assets.sort_by_key(|key| format!("{:?}", key.source)); + ( + SurfaceGraphicsScene { + assets, + placements, + retained_assets, + }, + next, + ) +} + +fn image_signature_from_asset(key: &SurfaceGraphicsAssetKey) -> ImageSignature { + ImageSignature { + image_width: key.image_width, + image_height: key.image_height, + format_code: format_code(key.format), + data_len: usize::try_from(key.data_len).unwrap_or(usize::MAX), + data_fingerprint: key.data_fingerprint, + } +} + +fn asset_key_from_descriptor( + source: SurfaceGraphicsSource, + descriptor: KittyImageDescriptor, +) -> SurfaceGraphicsAssetKey { + SurfaceGraphicsAssetKey { + source, + image_width: descriptor.image_width, + image_height: descriptor.image_height, + format: match descriptor.format { + KittyImageFormat::Rgb => SurfaceGraphicsFormat::Rgb, + KittyImageFormat::Rgba => SurfaceGraphicsFormat::Rgba, + KittyImageFormat::Png => SurfaceGraphicsFormat::Png, + }, + data_len: descriptor.data_len as u64, + data_fingerprint: descriptor.data_fingerprint, + } +} + +fn asset_key( + source: SurfaceGraphicsSource, + placement: &KittyImagePlacement, +) -> SurfaceGraphicsAssetKey { + SurfaceGraphicsAssetKey { + source, + image_width: placement.image_width, + image_height: placement.image_height, + format: match placement.format { + KittyImageFormat::Rgb => SurfaceGraphicsFormat::Rgb, + KittyImageFormat::Rgba => SurfaceGraphicsFormat::Rgba, + KittyImageFormat::Png => SurfaceGraphicsFormat::Png, + }, + data_len: placement.data_len as u64, + data_fingerprint: placement.data_fingerprint, + } +} + +fn client_host_placement( + scope: &str, + placement: &SurfaceGraphicsPlacement, + data: Option<&[u8]>, + visibility: Visibility, + main_origin: (u16, u16), + popup_origin: Option<(u16, u16)>, + cell_size: HostCellSize, +) -> Option { + let origin = match (&placement.asset.source, visibility) { + ( + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Popup { .. }, + .. + }, + Visibility::Popup, + ) => popup_origin?, + ( + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { .. }, + .. + }, + Visibility::Main | Visibility::Popup, + ) + | (SurfaceGraphicsSource::PaneLayer { .. }, Visibility::Main | Visibility::Popup) => { + main_origin + } + _ => return None, + }; + let source_key = HostSourceKey::ClientSurface { + scope: scope.to_owned(), + source: placement.asset.source.clone(), + }; + let signature = ImageSignature { + image_width: placement.asset.image_width, + image_height: placement.asset.image_height, + format_code: format_code(placement.asset.format), + data_len: usize::try_from(placement.asset.data_len).unwrap_or(usize::MAX), + data_fingerprint: placement.asset.data_fingerprint, + }; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + scope.hash(&mut hasher); + placement.asset.source.hash(&mut hasher); + signature.hash(&mut hasher); + let raw = hasher.finish(); + let pane_id = PaneId::from_raw((raw as u32).max(1)); + let host_image_id = host_image_id(scope, &placement.asset); + let cols = placement.cols.min(u32::from(u16::MAX)) as u16; + let rows = placement.rows.min(u32::from(u16::MAX)) as u16; + Some(HostPlacement { + pane_id, + host_image_id: Some(host_image_id), + area: Rect::new( + origin.0.saturating_add(placement.x), + origin.1.saturating_add(placement.y), + cols, + rows, + ), + cell_size, + source_key, + placement: KittyImagePlacement { + image_id: 1, + placement_id: placement.logical_placement_id, + z: placement.z, + x_offset: placement.x_offset, + y_offset: placement.y_offset, + image_width: placement.asset.image_width, + image_height: placement.asset.image_height, + format: match placement.asset.format { + SurfaceGraphicsFormat::Rgb => KittyImageFormat::Rgb, + SurfaceGraphicsFormat::Rgba => KittyImageFormat::Rgba, + SurfaceGraphicsFormat::Png => KittyImageFormat::Png, + }, + data_len: usize::try_from(placement.asset.data_len).unwrap_or(usize::MAX), + data_fingerprint: placement.asset.data_fingerprint, + data: data.unwrap_or_default().to_vec(), + render: KittyPlacementRenderInfo { + pixel_width: placement.cols.saturating_mul(cell_size.width_px), + pixel_height: placement.rows.saturating_mul(cell_size.height_px), + grid_cols: placement.cols, + grid_rows: placement.rows, + viewport_col: 0, + viewport_row: 0, + source_x: placement.source_x, + source_y: placement.source_y, + source_width: placement.source_width, + source_height: placement.source_height, + }, + }, + scrollback_offset: placement.scrollback_offset, + }) +} + +fn format_code(format: SurfaceGraphicsFormat) -> u32 { + match format { + SurfaceGraphicsFormat::Rgb => 24, + SurfaceGraphicsFormat::Rgba => 32, + SurfaceGraphicsFormat::Png => 100, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn asset( + target: SurfaceGraphicsTarget, + fingerprint: u64, + data: Vec, + ) -> SurfaceGraphicsAsset { + SurfaceGraphicsAsset { + key: SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target, + image_id: 7, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: data.len() as u64, + data_fingerprint: fingerprint, + }, + data, + } + } + + fn scene(asset: SurfaceGraphicsAsset, x: u16, y: u16) -> SurfaceGraphicsScene { + SurfaceGraphicsScene { + placements: vec![SurfaceGraphicsPlacement { + asset: asset.key.clone(), + logical_placement_id: 3, + 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, + }], + assets: vec![asset], + retained_assets: Vec::new(), + } + } + + #[test] + fn client_encodes_final_main_origin_upload_once_and_replays_placement() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 11, + vec![1, 2, 3, 4], + ); + state.set_scene(scene(image, 1, 2)); + + let first = state.encode( + Visibility::Main, + (10, 5), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + assert!(String::from_utf8_lossy(&first).contains("a=t,t=d")); + assert!(String::from_utf8_lossy(&first).contains("\u{1b}[8;12H")); + + let second = state.encode( + Visibility::Main, + (10, 5), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + let second = String::from_utf8_lossy(&second); + assert!(!second.contains("a=t,t=d")); + assert!(second.contains("a=p")); + assert!(second.contains("\u{1b}[8;12H")); + } + + #[test] + fn client_hides_and_restores_without_reuploading_pixels() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 12, + vec![4, 3, 2, 1], + ); + state.set_scene(scene(image, 0, 0)); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let _ = state.encode(Visibility::Main, (4, 2), None, cell); + + let hidden = state.encode(Visibility::Hidden, (4, 2), None, cell); + assert!(String::from_utf8_lossy(&hidden).contains("a=d,d=i")); + + let restored = state.encode(Visibility::Main, (4, 2), None, cell); + let restored = String::from_utf8_lossy(&restored); + assert!(restored.contains("a=p")); + assert!(!restored.contains("a=t,t=d")); + } + + #[test] + fn popup_candidates_use_client_resolved_popup_inner_origin() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let image = asset( + SurfaceGraphicsTarget::Popup { + terminal_id: "terminal-popup".into(), + }, + 13, + vec![9, 8, 7, 6], + ); + state.set_scene(scene(image, 2, 1)); + + let bytes = state.encode( + Visibility::Popup, + (20, 4), + Some((30, 10)), + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + assert!(String::from_utf8_lossy(&bytes).contains("\u{1b}[12;33H")); + } + + #[cfg(unix)] + #[test] + fn trusted_direct_asset_is_placed_without_inline_reupload() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let _ = state.encode( + Visibility::Hidden, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 15, + vec![1, 2, 3, 4], + ); + let image_id = host_image_id("endpoint-a:boot-1", &image.key); + assert!(state.trust_direct_asset(&image.key, image_id)); + let mut direct_scene = scene(image, 0, 0); + direct_scene.assets.clear(); + state.set_scene(direct_scene); + + let bytes = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + let bytes = String::from_utf8_lossy(&bytes); + assert!(bytes.contains("a=p")); + assert!(!bytes.contains("a=t,t=d")); + } + + #[cfg(unix)] + #[test] + fn direct_asset_trusted_after_scene_arrival_is_immediately_placeable() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let _ = state.take_pending_cleanup(); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 18, + vec![1, 2, 3, 4], + ); + let image_id = host_image_id("endpoint-a:boot-1", &image.key); + let mut direct_scene = scene(image.clone(), 0, 0); + direct_scene.assets.clear(); + state.set_scene(direct_scene); + assert!(state.trust_direct_asset(&image.key, image_id)); + + let bytes = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + let bytes = String::from_utf8_lossy(&bytes); + assert!(bytes.contains("a=p"), "{bytes}"); + assert!(!bytes.contains("a=t,t=d"), "{bytes}"); + } + + #[cfg(unix)] + #[test] + fn retained_direct_asset_survives_hidden_scene_and_replays_without_upload() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let _ = state.take_pending_cleanup(); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 19, + vec![1, 2, 3, 4], + ); + let image_id = host_image_id("endpoint-a:boot-1", &image.key); + let mut active = scene(image.clone(), 0, 0); + active.assets.clear(); + state.set_scene(active.clone()); + assert!(state.trust_direct_asset(&image.key, image_id)); + let _ = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + + state.set_scene(SurfaceGraphicsScene { + retained_assets: vec![image.key.clone()], + ..SurfaceGraphicsScene::default() + }); + let hidden = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + )) + .unwrap(); + assert!(!hidden.contains(&format!("a=d,d=I,i={image_id}"))); + + active.retained_assets.push(image.key.clone()); + state.set_scene(active); + let restored = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + )) + .unwrap(); + assert!(restored.contains("a=p"), "{restored}"); + assert!(!restored.contains("a=t,t=d"), "{restored}"); + + state.set_scene(SurfaceGraphicsScene::default()); + let removed = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + )) + .unwrap(); + assert!(removed.contains(&format!("a=d,d=I,i={image_id}"))); + } + + #[test] + fn popup_visibility_keeps_uncovered_main_scene_placements() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let main = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 20, + vec![1, 2, 3, 4], + ); + let popup = asset( + SurfaceGraphicsTarget::Popup { + terminal_id: "popup-1".into(), + }, + 21, + vec![4, 3, 2, 1], + ); + let mut graphics = scene(main, 0, 0); + let popup_scene = scene(popup, 0, 0); + graphics.assets.extend(popup_scene.assets); + graphics.placements.extend(popup_scene.placements); + state.set_scene(graphics); + + let bytes = String::from_utf8(state.encode( + Visibility::Popup, + (2, 1), + Some((20, 10)), + HostCellSize { + width_px: 8, + height_px: 16, + }, + )) + .unwrap(); + assert!(bytes.contains("\u{1b}[2;3H"), "{bytes}"); + assert!(bytes.contains("\u{1b}[11;21H"), "{bytes}"); + } + + #[cfg(unix)] + #[test] + fn retired_pending_direct_asset_is_deleted_even_before_cache_adoption() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let _ = state.take_pending_cleanup(); + state.retire_direct_image(4242); + let cleanup = String::from_utf8(state.take_pending_cleanup()).unwrap(); + assert!(cleanup.contains("a=d,d=I,i=4242"), "{cleanup}"); + } + + #[cfg(unix)] + #[test] + fn unclaimed_direct_asset_is_deleted_by_the_next_authoritative_scene() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let _ = state.take_pending_cleanup(); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 16, + vec![1, 2, 3, 4], + ); + let image_id = host_image_id("endpoint-a:boot-1", &image.key); + assert!(state.trust_direct_asset(&image.key, image_id)); + state.set_scene(SurfaceGraphicsScene::default()); + + let bytes = state.encode( + Visibility::Hidden, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + let bytes = String::from_utf8_lossy(&bytes); + assert!(bytes.contains(&format!("a=d,d=I,i={image_id}")), "{bytes}"); + } + + #[test] + fn boot_scope_cleanup_does_not_wait_for_a_coherent_surface() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 17, + vec![1, 2, 3, 4], + ); + state.set_scene(scene(image, 0, 0)); + let _ = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + + state.set_scope("endpoint-a:boot-2"); + let cleanup = String::from_utf8(state.take_pending_cleanup()).unwrap(); + assert!(cleanup.contains("a=d,d=I"), "{cleanup}"); + assert!(state.take_pending_cleanup().is_empty()); + } + + #[test] + fn replacement_scene_without_repeated_asset_bytes_keeps_resident_data() { + let mut state = ClientState::default(); + state.set_scope("endpoint-a:boot-1"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "w1:p1".into(), + }, + 14, + vec![1, 1, 1, 1], + ); + let first_scene = scene(image, 0, 0); + let mut replacement = first_scene.clone(); + replacement.assets.clear(); + state.set_scene(first_scene); + state.set_scene(replacement); + + let bytes = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + ); + assert!(String::from_utf8_lossy(&bytes).contains("a=t,t=d")); + } +} diff --git a/src/logging.rs b/src/logging.rs index ff85799b..2d1cd5a1 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -254,19 +254,6 @@ pub(crate) fn workspace_renamed(workspace_id: &str) { ); } -#[cfg(test)] -pub(crate) fn tab_created(workspace_id: &str, tab_id: &str, root_pane_id: u32) { - tracing::info!( - event = "tab.create", - subsystem = "tab", - outcome = "ok", - workspace_id, - tab_id, - pane_id = root_pane_id, - "tab created" - ); -} - pub(crate) fn tab_focused(workspace_id: &str, tab_id: &str) { tracing::info!( event = "tab.focus", @@ -383,18 +370,6 @@ pub(crate) fn update_available(version: &str) { ); } -pub(crate) fn config_write_failed(path: &Path, context: &str, err: &str) { - tracing::warn!( - event = "config.write", - subsystem = "config", - outcome = "error", - path = %path.display(), - context, - err, - "failed to write config" - ); -} - pub(crate) fn integration_action( action: &'static str, target: &'static str, diff --git a/src/main.rs b/src/main.rs index a6ba1c03..91a6b2e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,5 @@ use std::io; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, -}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; -use crossterm::execute; - pub(crate) const HERDR_ENV_VAR: &str = "HERDR_ENV"; pub(crate) const HERDR_ENV_VALUE: &str = "1"; const NESTED_HERDR_MESSAGES: [&str; 6] = [ @@ -19,41 +11,6 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ "recursion detected. base case not found. aborting.", ]; -#[cfg(not(windows))] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - execute!( - io::stdout(), - PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) - ) -} - -#[cfg(windows)] -fn push_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -#[cfg(not(windows))] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - execute!(io::stdout(), PopKeyboardEnhancementFlags) -} - -#[cfg(windows)] -fn pop_keyboard_enhancement_flags() -> io::Result<()> { - Ok(()) -} - -fn set_host_color_scheme_reports(enabled: bool) -> io::Result<()> { - use std::io::Write; - - let sequence = if enabled { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE - } else { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE - }; - io::stdout().write_all(sequence.as_bytes())?; - io::stdout().flush() -} - mod agent_resume; mod api; mod app; @@ -62,6 +19,7 @@ mod checksum; mod cli; mod client; mod config; +mod copy_mode; mod detect; mod events; mod ghostty; @@ -103,10 +61,6 @@ mod update; mod workspace; mod worktree; -fn init_logging() { - crate::logging::init_file_logging("herdr.log"); -} - const DEFAULT_CONFIG: &str = r##"# herdr configuration # Place this file at ~/.config/herdr/config.toml @@ -718,7 +672,6 @@ fn main() -> io::Result<()> { println!(" {:<32} Run as headless server", "herdr server"); println!(); println!("Options:"); - println!(" --no-session Run monolithically (no server/client, escape hatch)"); println!(" --session Use or create a named persistent session"); println!(" --remote Attach through SSH to a remote Herdr server"); println!(" --remote-keybindings "); @@ -758,7 +711,6 @@ fn main() -> io::Result<()> { // Reject unknown flags let known_flags = [ - "--no-session", "--session", "--remote", "--remote-keybindings", @@ -812,135 +764,11 @@ fn main() -> io::Result<()> { let loaded_config = config::Config::load(); exit_if_nested_disabled(&loaded_config.config); - let no_session = args.iter().any(|a| a == "--no-session"); - - // Auto-detect launch: when --no-session is NOT set, use server/client mode. - // Check if a server is running, spawn one if needed, then attach as client. - if !no_session { - if let Err(err) = server::autodetect::auto_detect_launch() { - eprintln!("herdr: {err}"); - std::process::exit(1); - } - return Ok(()); + if let Err(err) = server::autodetect::auto_detect_launch() { + eprintln!("herdr: {err}"); + std::process::exit(1); } - - // --- Monolithic mode (--no-session escape hatch) --- - // This is the pre-mission single-process behavior. - - init_logging(); - - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let _api_server = match api::start_server_with_capabilities(api_tx, event_hub.clone(), None) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr is already running"); - eprintln!("socket: {}", api::socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - let modify_other_keys_mode = crate::input::host_modify_other_keys_mode(); - - let original_hook = std::panic::take_hook(); - let panic_resets_modify_other_keys = modify_other_keys_mode.is_some(); - std::panic::set_hook(Box::new(move |info| { - tracing::error!("PANIC: {info}"); - if panic_resets_modify_other_keys { - let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m"); - } - if crate::kitty_graphics::is_enabled() { - let _ = crate::kitty_graphics::clear_all_host_graphics(); - } - let _ = execute!( - io::stdout(), - DisableFocusChange, - DisableBracketedPaste, - DisableMouseCapture - ); - let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout()); - let _ = set_host_color_scheme_reports(false); - let _ = pop_keyboard_enhancement_flags(); - ratatui::restore(); - original_hook(info); - })); - - let config = &loaded_config.config; - let config_diagnostic = config::config_diagnostic_summary(&loaded_config.diagnostics); - logging::startup("app"); - - // Background update check (non-blocking, best-effort) - // Only checks for newer versions and notifies the TUI. - // Skipped in --no-session mode (testing). - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to create tokio runtime"); - - let result = rt.block_on(async { - let mut terminal = ratatui::init(); - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - if config.ui.mouse_capture { - execute!(io::stdout(), EnableMouseCapture)?; - } else { - execute!(io::stdout(), DisableMouseCapture)?; - } - execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; - set_host_color_scheme_reports(true)?; - push_keyboard_enhancement_flags()?; - - // Some hosts do not honor Kitty keyboard enhancement pushes for - // Shift+Enter. Enable xterm modifyOtherKeys only on hosts where we - // know it is needed and parseable, so modified Enter stays distinct. - if let Some(mode) = modify_other_keys_mode { - use std::io::Write; - std::io::stdout().write_all(mode.set_sequence())?; - std::io::stdout().flush()?; - } - - let mut app = app::App::new( - config, - true, // no_session — monolithic mode never saves/restores sessions - config_diagnostic, - api_rx, - event_hub, - ); - let result = app.run(&mut terminal).await; - - // Reset modifyOtherKeys if we enabled it. - if modify_other_keys_mode.is_some() { - use std::io::Write; - std::io::stdout().write_all(b"\x1b[>4;0m")?; - std::io::stdout().flush()?; - } - - if crate::kitty_graphics::is_enabled() { - crate::kitty_graphics::clear_all_host_graphics()?; - } - pop_keyboard_enhancement_flags()?; - execute!( - io::stdout(), - DisableFocusChange, - DisableBracketedPaste, - DisableMouseCapture - )?; - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - set_host_color_scheme_reports(false)?; - ratatui::restore(); - - // Drop app (and all workspaces/panes) before runtime shuts down - drop(app); - - result - }); - - // Shut down runtime immediately — kills lingering PTY reader/writer tasks - rt.shutdown_timeout(std::time::Duration::from_millis(100)); - - logging::shutdown("app"); - result + Ok(()) } #[cfg(test)] diff --git a/src/pane.rs b/src/pane.rs index 7340629c..48928034 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -44,8 +44,8 @@ use self::agent_detection::{ pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{ - TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalTextMatch, - TerminalTextPoint, TerminalWordMotion, + TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalReadSnapshot, TerminalSearchDirection, + TerminalSearchWindow, TerminalTextPoint, TerminalWordMotion, }; pub use self::{ state::PaneState, @@ -56,21 +56,6 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration:: const PANE_TERM: &str = "xterm-256color"; const PANE_COLORTERM: &str = "truecolor"; -#[cfg(test)] -thread_local! { - static AGGREGATE_INPUT_STATE_READS: Cell = const { Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn reset_aggregate_input_state_reads() { - AGGREGATE_INPUT_STATE_READS.set(0); -} - -#[cfg(test)] -pub(crate) fn aggregate_input_state_reads() -> usize { - AGGREGATE_INPUT_STATE_READS.get() -} - fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // Each pane is rendered by herdr's own terminal layer, not the outer terminal // that launched the app. Advertising the inherited TERM leaks the host terminal @@ -1053,6 +1038,7 @@ pub struct PaneRuntime { child_wait_completed: Option>, kitty_keyboard_flags: Arc, content_seq: Arc, + content_write_lock: Arc>, detection_content_seq: Arc, full_lifecycle_authority_active: Arc, detect_reset_notify: Arc, @@ -1175,14 +1161,6 @@ impl PaneRuntimeIo { } } - async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - match self { - PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await, - #[cfg(test)] - PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await, - } - } - fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { match self { PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes), @@ -1926,6 +1904,7 @@ impl PaneRuntime { let reported_cwd = Arc::new(Mutex::new(None)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); let content_seq = Arc::new(AtomicU64::new(0)); + let content_write_lock = Arc::new(Mutex::new(())); let detection_content_seq = Arc::new(AtomicU64::new(0)); let io = { @@ -1934,6 +1913,7 @@ impl PaneRuntime { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); + let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let read_events = events.clone(); @@ -1941,11 +1921,16 @@ impl PaneRuntime { let rt = tokio::runtime::Handle::current(); let delay_rt = rt.clone(); let on_read = Box::new(move |bytes: &[u8]| { + let _content_write_guard = match content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &read_events); observe_detection_content_change(bytes, &detection_content_seq); let title_requested = @@ -2014,6 +1999,7 @@ impl PaneRuntime { child_wait_completed: None, kitty_keyboard_flags, content_seq, + content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, @@ -2061,6 +2047,7 @@ impl PaneRuntime { } let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); + let content_write_lock = Arc::new(Mutex::new(())); let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd) .inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?; @@ -2104,17 +2091,23 @@ impl PaneRuntime { let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let content_seq = content_seq.clone(); + let content_write_lock = content_write_lock.clone(); let detection_content_seq = detection_content_seq.clone(); let child_pid = child_pid.clone(); let events = events.clone(); let reported_cwd = reported_cwd.clone(); let rt = tokio::runtime::Handle::current(); let on_read = Box::new(move |bytes: &[u8]| { + let _content_write_guard = match content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; content_seq.fetch_add(1, Ordering::AcqRel); let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); publish_terminal_bells(pane_id, result.terminal_bells, &events); if agent_detection == AgentDetection::Enabled { observe_detection_content_change(bytes, &detection_content_seq); @@ -2570,6 +2563,7 @@ impl PaneRuntime { child_wait_completed: Some(child_wait_completed), kitty_keyboard_flags, content_seq, + content_write_lock, detection_content_seq, full_lifecycle_authority_active, detect_reset_notify, @@ -2598,11 +2592,6 @@ impl PaneRuntime { self.detect_reset_notify.clone() } - #[cfg(test)] - pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { - self.detect_handle.is_some() - } - pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -2630,9 +2619,16 @@ impl PaneRuntime { return; } self.current_size.set(size); + let _content_write_guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + self.content_seq.fetch_add(1, Ordering::AcqRel); let terminal_responses = self .terminal .resize(rows, cols, cell_width_px, cell_height_px); + self.content_seq.fetch_add(1, Ordering::Release); + drop(_content_write_guard); mark_detection_content_changed(&self.detection_content_seq); self.io.resize( rows, @@ -2674,23 +2670,20 @@ impl PaneRuntime { self.terminal.scroll_metrics() } - pub(crate) fn search_text_matches( + pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, - ) -> Vec { - self.terminal.search_text_matches(query, case_sensitive) - } - - pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { - self.terminal.text_match_is_current(text_match) - } - - pub(crate) fn text_matches_are_current( - &self, - text_matches: &[crate::pane::TerminalTextMatch], - ) -> Vec { - self.terminal.text_matches_are_current(text_matches) + direction: crate::pane::TerminalSearchDirection, + cursor: crate::pane::TerminalTextPoint, + previous: Option<( + crate::pane::TerminalTextPoint, + crate::pane::TerminalTextPoint, + )>, + limit: usize, + ) -> crate::pane::TerminalSearchWindow { + self.terminal + .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } pub(crate) fn word_motion_target( @@ -2702,15 +2695,21 @@ impl PaneRuntime { self.terminal.word_motion_target(row, col, motion) } - #[cfg(any(unix, test))] - pub fn input_state(&self) -> Option { - #[cfg(test)] - AGGREGATE_INPUT_STATE_READS.set(AGGREGATE_INPUT_STATE_READS.get() + 1); - self.terminal.input_state() + pub(crate) fn terminal_dimensions(&self) -> Option<(u16, u16)> { + self.terminal.dimensions() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.terminal.keyboard_report_all_requested() + pub(crate) fn paragraph_motion_target( + &self, + row: u32, + direction: i8, + ) -> Option { + self.terminal.paragraph_motion_target(row, direction) + } + + #[cfg(any(unix, test))] + pub fn input_state(&self) -> Option { + self.terminal.input_state() } pub fn bracketed_paste_enabled(&self) -> bool { @@ -2844,15 +2843,15 @@ impl PaneRuntime { self.terminal.keyboard_protocol(fallback) } + pub fn modify_other_keys_level(&self) -> u8 { + self.terminal.modify_other_keys_level() + } + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { self.terminal .encode_terminal_key(key, self.keyboard_protocol()) } - pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - self.io.send_bytes(bytes).await - } - pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { self.io.try_send_bytes(bytes) } @@ -2861,10 +2860,6 @@ impl PaneRuntime { self.io.send_bytes_after(bytes, delay); } - pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { - self.send_bytes(self.paste_payload(text)).await - } - pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { self.try_send_bytes(self.paste_payload(text)) } @@ -3047,6 +3042,10 @@ impl PaneRuntime { } pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) { + let _content_write_guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; self.content_seq.fetch_add(1, Ordering::AcqRel); let (tx, _rx) = mpsc::channel(1); let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx); @@ -3091,6 +3090,7 @@ impl PaneRuntime { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), @@ -3660,6 +3660,7 @@ mod tests { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), @@ -3692,6 +3693,7 @@ mod tests { child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), + content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index 30ab62ba..74de2538 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -3,8 +3,7 @@ pub(crate) struct KittyKeyboardTracker { pending: Vec, stack: Vec, flags: u16, - #[cfg(windows)] - modify_other_keys: bool, + modify_other_keys_level: u8, } impl KittyKeyboardTracker { @@ -32,9 +31,10 @@ impl KittyKeyboardTracker { self.store_pending(&bytes[index..]); break; } - #[cfg(windows)] if bytes[index + 1] == b'c' { - self.modify_other_keys = false; + self.stack.clear(); + self.flags = 0; + self.modify_other_keys_level = 0; } if bytes[index + 1] != b'[' { index += 1; @@ -52,7 +52,6 @@ impl KittyKeyboardTracker { match bytes[end] { b'u' => self.observe_csi_u(&bytes[index + 2..end]), - #[cfg(windows)] b'm' => self.observe_modify_other_keys(&bytes[index + 2..end]), #[cfg(windows)] b'n' if bytes[index + 2..end] @@ -61,7 +60,7 @@ impl KittyKeyboardTracker { !params.contains(&b';') && parse_kitty_keyboard_flags(params) == 4 }) => { - self.modify_other_keys = false; + self.modify_other_keys_level = 0; } _ => {} } @@ -69,18 +68,21 @@ impl KittyKeyboardTracker { } } - #[cfg(windows)] - pub(crate) fn modify_other_keys_enabled(&self) -> bool { - self.modify_other_keys + pub(crate) fn modify_other_keys_level(&self) -> u8 { + self.modify_other_keys_level } #[cfg(windows)] + pub(crate) fn modify_other_keys_enabled(&self) -> bool { + self.modify_other_keys_level > 0 + } + fn observe_modify_other_keys(&mut self, params: &[u8]) { let Some(params) = params.strip_prefix(b">") else { return; }; if params.is_empty() { - self.modify_other_keys = false; + self.modify_other_keys_level = 0; return; } @@ -91,8 +93,8 @@ impl KittyKeyboardTracker { return; } if parse_kitty_keyboard_flags(resource) == 4 { - self.modify_other_keys = - value.is_some_and(|value| parse_kitty_keyboard_flags(value) != 0); + self.modify_other_keys_level = + value.map(parse_kitty_keyboard_flags).unwrap_or(0).min(2) as u8; } } @@ -128,17 +130,22 @@ impl KittyKeyboardTracker { #[cfg(unix)] pub(crate) fn replay_ansi(&self) -> Option { - if self.stack.is_empty() { - return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags)); - } - let mut ansi = String::new(); - let baseline = self.stack[0]; - if baseline != 0 { - ansi.push_str(&format!("\x1b[={baseline}u")); + if self.stack.is_empty() { + if self.flags != 0 { + ansi.push_str(&format!("\x1b[={}u", self.flags)); + } + } else { + let baseline = self.stack[0]; + if baseline != 0 { + ansi.push_str(&format!("\x1b[={baseline}u")); + } + for flags in self.stack.iter().skip(1).copied().chain([self.flags]) { + ansi.push_str(&format!("\x1b[>{flags}u")); + } } - for flags in self.stack.iter().skip(1).copied().chain([self.flags]) { - ansi.push_str(&format!("\x1b[>{flags}u")); + if self.modify_other_keys_level > 0 { + ansi.push_str(&format!("\x1b[>4;{}m", self.modify_other_keys_level)); } (!ansi.is_empty()).then_some(ansi) } @@ -166,6 +173,7 @@ mod tests { assert_eq!(tracker.flags, 1); assert_eq!(tracker.stack, vec![0]); + assert_eq!(tracker.modify_other_keys_level(), 1); #[cfg(windows)] { assert!(tracker.modify_other_keys_enabled()); @@ -175,4 +183,31 @@ mod tests { assert!(!tracker.modify_other_keys_enabled()); } } + + #[test] + fn ris_clears_kitty_flags_stack_and_modify_other_keys() { + let mut tracker = KittyKeyboardTracker::default(); + tracker.observe(b"\x1b[>1u\x1b[>5u\x1b[>4;2m\x1bc"); + + assert_eq!(tracker.flags, 0); + assert!(tracker.stack.is_empty()); + assert_eq!(tracker.modify_other_keys_level(), 0); + #[cfg(unix)] + assert_eq!(tracker.replay_ansi(), None); + } + + #[test] + fn tracks_and_replays_exact_modify_other_keys_level() { + let mut tracker = KittyKeyboardTracker::default(); + + tracker.observe(b"\x1b[>4;1m"); + assert_eq!(tracker.modify_other_keys_level(), 1); + #[cfg(unix)] + assert_eq!(tracker.replay_ansi().as_deref(), Some("\x1b[>4;1m")); + + tracker.observe(b"\x1b[>4;2m"); + assert_eq!(tracker.modify_other_keys_level(), 2); + tracker.observe(b"\x1b[>4;0m"); + assert_eq!(tracker.modify_other_keys_level(), 0); + } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 37de333b..df84e5cb 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -68,6 +68,20 @@ pub(crate) struct TerminalTextMatch { pub scan_screen: crate::ghostty::ActiveScreen, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TerminalSearchDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalSearchWindow { + pub matches: Vec, + pub current: Option, + pub current_global: Option, + pub total: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TerminalWordMotion { NextStart, @@ -241,69 +255,32 @@ impl PaneTerminal { self.ghostty.scroll_metrics() } - pub(crate) fn search_text_matches( + pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, - ) -> Vec { + direction: TerminalSearchDirection, + cursor: TerminalTextPoint, + previous: Option<(TerminalTextPoint, TerminalTextPoint)>, + limit: usize, + ) -> TerminalSearchWindow { let Some((buffer, active_screen)) = self.retained_text_buffer() else { - return Vec::new(); + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; }; - buffer.search(query, case_sensitive, active_screen) - } - - pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool { - self.text_matches_are_current(&[text_match]) - .first() - .copied() - .unwrap_or(false) - } - - pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec { - if text_matches.is_empty() { - return Vec::new(); - } - let Ok(core) = self.ghostty.core.lock() else { - return vec![false; text_matches.len()]; - }; - let Some(cols) = core.terminal.cols().ok() else { - return vec![false; text_matches.len()]; - }; - let Some(active_screen) = core.terminal.active_screen().ok() else { - return vec![false; text_matches.len()]; - }; - let row_range = text_matches - .iter() - .filter(|text_match| { - text_match.scan_cols == cols && text_match.scan_screen == active_screen - }) - .fold(None::<(u32, u32)>, |range, text_match| { - Some(match range { - Some((start_row, end_row)) => ( - start_row.min(text_match.start.row), - end_row.max(text_match.end.row), - ), - None => (text_match.start.row, text_match.end.row), - }) - }); - let Some((start_row, end_row)) = row_range else { - return vec![false; text_matches.len()]; - }; - let Ok(rows) = core - .terminal - .screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize) - else { - return vec![false; text_matches.len()]; - }; - let buffer = RetainedTextBuffer::new_search(cols, rows, start_row); - text_matches - .iter() - .map(|text_match| { - text_match.scan_cols == cols - && text_match.scan_screen == active_screen - && buffer.contains_match(*text_match) - }) - .collect() + buffer.search_window( + query, + case_sensitive, + active_screen, + direction, + cursor, + previous, + limit, + ) } pub(crate) fn word_motion_target( @@ -377,6 +354,53 @@ impl PaneTerminal { } } + pub(crate) fn dimensions(&self) -> Option<(u16, u16)> { + let core = self.ghostty.core.lock().ok()?; + Some((core.terminal.cols().ok()?, core.terminal.rows().ok()?)) + } + + pub(crate) fn paragraph_motion_target( + &self, + row: u32, + direction: i8, + ) -> Option { + let core = self.ghostty.core.lock().ok()?; + let total_rows = core.terminal.total_rows().ok()?; + let current = usize::try_from(row).ok()?; + if current >= total_rows || direction == 0 { + return None; + } + let limit = total_rows.min(1000); + for distance in 1..limit { + let candidate = if direction < 0 { + current.checked_sub(distance)? + } else { + let candidate = current.saturating_add(distance); + if candidate >= total_rows { + return None; + } + candidate + }; + let rows = core + .terminal + .screen_text_rows_range(candidate, candidate.saturating_add(1)) + .ok()?; + let row = rows.first()?; + let is_blank = row.cells.iter().all(|cell| { + terminal_cell_text(&cell.graphemes) + .chars() + .all(char::is_whitespace) + }); + if is_blank { + return Some(TerminalTextPoint { + row: u32::try_from(candidate).ok()?, + col: 0, + }); + } + } + None + } + fn retained_text_buffer(&self) -> Option<(RetainedTextBuffer, crate::ghostty::ActiveScreen)> { let (cols, rows, active_screen) = { let core = self.ghostty.core.lock().ok()?; @@ -393,10 +417,6 @@ impl PaneTerminal { self.ghostty.input_state() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.ghostty.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.ghostty.bracketed_paste_enabled() } @@ -409,6 +429,10 @@ impl PaneTerminal { self.ghostty.mouse_reporting_enabled() } + pub fn modify_other_keys_level(&self) -> u8 { + self.ghostty.modify_other_keys_level() + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.ghostty.sgr_pixel_mouse_enabled() } @@ -530,12 +554,10 @@ impl PaneTerminal { self.ghostty.terminal_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.ghostty.agent_osc_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.ghostty.agent_osc_progress() } @@ -742,70 +764,122 @@ impl RetainedTextBuffer { Self { cols, lines, atoms } } - fn search( + fn search_window( &self, query: &str, case_sensitive: bool, active_screen: crate::ghostty::ActiveScreen, - ) -> Vec { - if query.is_empty() { - return Vec::new(); + direction: TerminalSearchDirection, + cursor: TerminalTextPoint, + previous: Option<(TerminalTextPoint, TerminalTextPoint)>, + limit: usize, + ) -> TerminalSearchWindow { + if query.is_empty() || limit == 0 { + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; } let Ok(regex) = regex::RegexBuilder::new(®ex::escape(query)) .case_insensitive(!case_sensitive) .build() else { - return Vec::new(); + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; }; - let mut matches = Vec::new(); - for line in &self.lines { - for found in regex.find_iter(&line.text) { - let Ok(start_index) = line - .spans - .binary_search_by_key(&found.start(), |span| span.byte_start) - else { - continue; - }; - let Ok(end_index) = line - .spans - .binary_search_by_key(&found.end(), |span| span.byte_end) - else { - continue; - }; - let start_span = &line.spans[start_index]; - let end_span = &line.spans[end_index]; - matches.push(TerminalTextMatch { - start: start_span.start, - end: end_span.end, - source_fingerprint: text_fingerprint(found.as_str()), - scan_cols: self.cols, - scan_screen: active_screen, - }); - } - } - matches - } - - fn contains_match(&self, text_match: TerminalTextMatch) -> bool { - self.lines.iter().any(|line| { - let Ok(start_index) = line + let to_match = |line: &LogicalTextLine, found: regex::Match<'_>| { + let start_index = line .spans - .binary_search_by_key(&text_match.start, |span| span.start) - else { - return false; - }; - let Ok(end_index) = line + .binary_search_by_key(&found.start(), |span| span.byte_start) + .ok()?; + let end_index = line .spans - .binary_search_by_key(&text_match.end, |span| span.end) - else { - return false; - }; + .binary_search_by_key(&found.end(), |span| span.byte_end) + .ok()?; let start_span = &line.spans[start_index]; let end_span = &line.spans[end_index]; - start_span.byte_start <= end_span.byte_end - && text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end]) - == text_match.source_fingerprint - }) + Some(TerminalTextMatch { + start: start_span.start, + end: end_span.end, + source_fingerprint: text_fingerprint(found.as_str()), + scan_cols: self.cols, + scan_screen: active_screen, + }) + }; + + let origin = match direction { + TerminalSearchDirection::Forward => previous.map_or(cursor, |(_, end)| end), + TerminalSearchDirection::Backward => previous.map_or(cursor, |(start, _)| start), + }; + let mut total = 0usize; + let mut target = None; + for line in &self.lines { + for found in regex.find_iter(&line.text) { + let Some(text_match) = to_match(line, found) else { + continue; + }; + match direction { + TerminalSearchDirection::Forward + if target.is_none() && text_match.start > origin => + { + target = Some(total); + } + TerminalSearchDirection::Backward if text_match.end < origin => { + target = Some(total); + } + _ => {} + } + total = total.saturating_add(1); + } + } + if total == 0 { + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; + } + let target = target.unwrap_or(match direction { + TerminalSearchDirection::Forward => 0, + TerminalSearchDirection::Backward => total - 1, + }); + let retained = limit.min(total); + let start = target + .saturating_sub(retained / 2) + .min(total.saturating_sub(retained)); + let end = start.saturating_add(retained); + let mut index = 0usize; + let mut matches = Vec::with_capacity(retained); + for line in &self.lines { + for found in regex.find_iter(&line.text) { + let Some(text_match) = to_match(line, found) else { + continue; + }; + if index >= start && index < end { + matches.push(text_match); + } + index = index.saturating_add(1); + if index >= end { + break; + } + } + if index >= end { + break; + } + } + TerminalSearchWindow { + matches, + current: Some(target - start), + current_global: Some(target), + total, + } } fn word_motion( @@ -1187,7 +1261,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 0/2 title retained for agent detection, or `""` /// if no title has been seen or the last update was an empty clear. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.core .lock() @@ -1197,7 +1270,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 9 progress payload retained for agent detection, /// or `""` if none has been seen. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.core .lock() @@ -1449,7 +1521,6 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; - #[cfg(windows)] core.kitty_keyboard.observe(ansi.as_bytes()); core.terminal.write(ansi.as_bytes()); #[cfg(windows)] @@ -1514,6 +1585,7 @@ impl GhosttyPaneTerminal { } if input_state.modify_other_keys { + core.kitty_keyboard.observe(b"\x1b[>4;2m"); core.terminal.write(b"\x1b[>4;2m"); } @@ -1675,17 +1747,6 @@ impl GhosttyPaneTerminal { core.kitty_keyboard.replay_ansi() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.core.lock().is_ok_and(|core| { - let protocol = crate::input::KeyboardProtocol::from_kitty_flags( - core.terminal.kitty_keyboard_flags().unwrap_or(0) as u16, - ); - protocol.reports_all_keys() - || (protocol.reports_event_types() - && core.terminal.modify_other_keys_enabled().unwrap_or(false)) - }) - } - pub fn bracketed_paste_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_BRACKETED_PASTE) } @@ -1700,6 +1761,12 @@ impl GhosttyPaneTerminal { .is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false)) } + pub fn modify_other_keys_level(&self) -> u8 { + self.core + .lock() + .map_or(0, |core| core.kitty_keyboard.modify_other_keys_level()) + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_MOUSE_SGR_PIXELS) } @@ -2363,12 +2430,16 @@ fn ghostty_collect_dirty_patch( if render_state.update(terminal).is_err() { fallback!("render_state_update_error"); } - match render_state.dirty() { + let collect_all_rows = match render_state.dirty() { Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean), - Ok(crate::ghostty::Dirty::Partial) => {} - Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"), + Ok(crate::ghostty::Dirty::Partial) => false, + // A full dirty state means that every visible row may have changed. It + // is still safe to send this as a bounded patch: the client replaces + // only this pane's viewport, rather than falling back to the whole + // shell surface. + Ok(crate::ghostty::Dirty::Full) => true, Err(_) => fallback!("dirty_read_error"), - } + }; let colors = render_state.colors().ok(); let default_bg = colors @@ -2399,7 +2470,7 @@ fn ghostty_collect_dirty_patch( let Ok(dirty) = rows.dirty() else { fallback!("row_dirty_read_error"); }; - if dirty { + if collect_all_rows || dirty { match rows.selection() { Ok(None) => {} Ok(Some(_)) => fallback!("row_selection_present"), @@ -2448,6 +2519,9 @@ fn ghostty_collect_dirty_patch( y += 1; } + // Nothing above mutates dirty state. Only clear it after every row has + // been collected successfully, so a safety fallback leaves the next + // collection with the same information. let dirty_ys: std::collections::HashSet = patch_rows.iter().map(|(row, _)| *row).collect(); if !dirty_ys.is_empty() { let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else { @@ -3346,6 +3420,30 @@ mod tests { crate::ghostty::RgbColor { r, g, b } } + #[test] + fn dirty_full_collects_bounded_viewport_patch() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(4, 3, 200).unwrap(); + terminal.write(b"one\r\ntwo\r\nthree"); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + let patch = match pane.collect_dirty_patch(4, 3) { + TerminalDirtyPatchOutcome::Patch(patch) => patch, + outcome => panic!("expected viewport patch, got {outcome:?}"), + }; + + assert_eq!(patch.rows.len(), 3); + assert_eq!( + patch.rows.iter().map(|(row, _)| *row).collect::>(), + vec![0, 1, 2] + ); + assert!(patch.rows.iter().all(|(_, cells)| cells.len() == 4)); + assert!(matches!( + pane.collect_dirty_patch(4, 3), + TerminalDirtyPatchOutcome::Clean + )); + } + #[test] fn palette_overrides_are_none_without_an_osc4_write() { let default = [rgb(1, 2, 3); 256]; @@ -3419,7 +3517,17 @@ mod tests { query: &str, case_sensitive: bool, ) -> Vec { - buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary) + buffer + .search_window( + query, + case_sensitive, + crate::ghostty::ActiveScreen::Primary, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + usize::MAX, + ) + .matches } fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { @@ -3651,56 +3759,6 @@ mod tests { ); } - #[test] - fn live_terminal_match_validation_rejects_overwritten_text() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - assert!(pane.text_match_is_current(text_match)); - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\r\x1b[2Kalpha changed"); - assert!(!pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_handles_soft_wrapped_matches() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); - terminal.write(b"abcdef"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("def", true)[0]; - - assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 }); - assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 }); - assert!(pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_rejects_an_active_screen_change() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\x1b[?1049hneedle"); - - assert!(!pane.text_match_is_current(text_match)); - } - #[test] fn live_terminal_word_motion_expands_across_long_blank_history() { let (tx, _rx) = mpsc::channel(4); @@ -3725,7 +3783,16 @@ mod tests { let word = "a".repeat(132); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; assert_eq!( pane.word_motion_target( @@ -3744,7 +3811,16 @@ mod tests { let word = "界".repeat(66); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; // The word end sits on the head cell of the final wide glyph, past the // initial read window, so the window has to expand to reach it. @@ -4476,6 +4552,7 @@ mod tests { color_scheme_reporting: true, }) ); + assert_eq!(pane.modify_other_keys_level(), 2); let encoded = pane.encode_terminal_key( crate::input::TerminalKey::new( @@ -4693,6 +4770,7 @@ mod tests { let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); + assert_eq!(pane.modify_other_keys_level(), 1); let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[27;2;13~"); @@ -5052,35 +5130,6 @@ mod tests { assert_eq!(buffer[(2, 0)].symbol(), "Z"); } - #[test] - fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 1); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write("ガZ".as_bytes()); - } - - let patch = match pane.collect_dirty_patch(20, 1) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - let row = &patch.rows[0].1; - - assert_eq!(row[0].symbol, "カ\u{ff9e}"); - assert_eq!( - row[1].symbol, "", - "wide spacer tail must stay empty in retained terminal patches" - ); - assert_eq!(row[2].symbol, "Z"); - } - #[test] fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); @@ -5973,34 +6022,6 @@ mod tests { assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51))); } - #[test] - fn dirty_patch_preserves_curly_underline_style() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 5); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write(b"\x1b[4:3mU"); - } - - let patch = match pane.collect_dirty_patch(20, 5) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - - let cell = &patch.rows[0].1[0]; - assert_eq!(cell.symbol, "U"); - assert_eq!( - crate::protocol::underline_style_from_modifier(cell.modifier), - 3 - ); - } - #[test] fn full_frame_preserves_curly_underline_style() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 39f790dd..e6ca70f9 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -258,9 +258,6 @@ pub fn capture( terminal_runtimes: &TerminalRuntimeRegistry, active: Option, selected: usize, - sidebar_width: u16, - sidebar_section_split: f32, - collapsed_space_keys: std::collections::HashSet, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, @@ -270,9 +267,9 @@ pub fn capture( .collect(), active, selected, - sidebar_width: Some(sidebar_width), - sidebar_section_split: Some(sidebar_section_split), - collapsed_space_keys, + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: std::collections::HashSet::new(), } } @@ -538,9 +535,6 @@ mod tests { terminal_runtimes, state.active, state.selected, - state.sidebar_width, - state.sidebar_section_split, - state.collapsed_space_keys.clone(), ) } @@ -867,16 +861,13 @@ mod tests { } #[test] - fn capture_contract_tracks_sidebar_state() { - let mut state = state_with_workspaces(&["one"]); - state.sidebar_width = 31; - state.sidebar_section_split = 0.4; - state.collapsed_space_keys.insert("repo-key".into()); + fn capture_contract_omits_legacy_server_chrome_state() { + let state = state_with_workspaces(&["one"]); let snapshot = capture_from_state(&state); - assert_eq!(snapshot.sidebar_width, Some(31)); - assert_eq!(snapshot.sidebar_section_split, Some(0.4)); - assert!(snapshot.collapsed_space_keys.contains("repo-key")); + assert_eq!(snapshot.sidebar_width, None); + assert_eq!(snapshot.sidebar_section_split, None); + assert!(snapshot.collapsed_space_keys.is_empty()); } #[test] @@ -920,7 +911,11 @@ mod tests { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; let second = state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, 106, 20), + ); state.navigate_pane(NavDirection::Right); @@ -935,7 +930,11 @@ mod tests { let root = state.workspaces[0].tabs[0].root_pane; state.workspaces[0].test_split(Direction::Horizontal); state.workspaces[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + Rect::new(0, 0, 106, 20), + ); let before = capture_from_state(&state); state.resize_pane(NavDirection::Right); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 64e5467b..7e14dcd8 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -220,8 +220,6 @@ pub fn open_url(_url: &str) -> std::io::Result> { } /// Unsupported platform stub. -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub fn read_clipboard_image() -> Option { None } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 6323212a..fc80bc64 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -201,6 +201,14 @@ mod unix_common; #[cfg(unix)] pub(crate) use unix_common::{begin_cli_output, end_cli_output}; +#[cfg(not(windows))] +pub(crate) fn replace_file( + source: &std::path::Path, + destination: &std::path::Path, +) -> std::io::Result<()> { + std::fs::rename(source, destination) +} + #[cfg(not(unix))] pub(crate) fn begin_cli_output() {} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 413119ed..e63b8920 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,6 +15,39 @@ use std::{ mod clipboard_image; +pub(crate) fn replace_file( + source: &std::path::Path, + destination: &std::path::Path, +) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + pub(crate) fn set_default_plugin_pane_pwd( _env: &mut Vec<(String, String)>, _cwd: &std::path::Path, diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 97685d4d..fe3ab68f 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -31,7 +31,9 @@ use std::io::Write; use unicode_width::UnicodeWidthStr; -use crate::protocol::{underline_style_from_modifier, CellData, FrameData}; +use crate::protocol::{ + underline_style_from_modifier, CellData, CursorState, FrameData, PaneSurfacePatchRow, +}; const REVERSED_MODIFIER: u16 = 1 << 6; const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l"; @@ -135,8 +137,101 @@ impl BlitEncoder { self.last_frame.as_ref() == Some(frame) } - pub(crate) fn last_frame(&self) -> Option<&FrameData> { - self.last_frame.as_ref() + pub(crate) fn encode_patch( + &self, + rows: &[PaneSurfacePatchRow], + cursor: Option, + suppress_visible_cursor: bool, + ) -> Option { + let frame = self.last_frame.as_ref()?; + if rows.iter().any(|row| !patch_row_fits(frame, row)) || patch_rows_overlap(rows) { + return None; + } + let mut bytes = Vec::new(); + let mut next_last_visible_cursor = self.last_visible_cursor; + let mut next_last_cursor_shape = self.last_cursor_shape; + blit_patch_to( + &mut bytes, + frame, + rows, + cursor, + &mut next_last_visible_cursor, + &mut next_last_cursor_shape, + repeat_ime_anchor_after_sync(), + suppress_visible_cursor, + ); + Some(EncodedBlit { + bytes, + full: false, + next_last_visible_cursor, + next_last_cursor_shape, + }) + } + + pub(crate) fn patch_rows_with_drawn_cursor( + &self, + rows: &[PaneSurfacePatchRow], + cursor: Option<&CursorState>, + ) -> Option> { + let frame = self.last_frame.as_ref()?; + let mut rows = rows.to_vec(); + let previous = frame + .cursor + .as_ref() + .filter(|cursor| cursor.visible) + .map(|cursor| clamp_cursor_position(frame, cursor.x, cursor.y)); + let next = cursor + .filter(|cursor| cursor.visible) + .map(|cursor| clamp_cursor_position(frame, cursor.x, cursor.y)); + + if let Some((x, y)) = previous.filter(|position| Some(*position) != next) { + if patch_cell_mut(&mut rows, x, y).is_none() { + let mut cell = frame.cells.get(frame_cell_index(frame, x, y)?)?.clone(); + cell.modifier ^= REVERSED_MODIFIER; + rows.push(PaneSurfacePatchRow { + x, + y, + cells: vec![cell], + }); + } + } + if let Some((x, y)) = next { + if let Some(cell) = patch_cell_mut(&mut rows, x, y) { + cell.modifier ^= REVERSED_MODIFIER; + } else if previous != next { + let mut cell = frame.cells.get(frame_cell_index(frame, x, y)?)?.clone(); + cell.modifier ^= REVERSED_MODIFIER; + rows.push(PaneSurfacePatchRow { + x, + y, + cells: vec![cell], + }); + } + } + Some(rows) + } + + pub(crate) fn commit_patch( + &mut self, + rows: &[PaneSurfacePatchRow], + cursor: Option, + encoded: EncodedBlit, + ) -> bool { + let Some(frame) = self.last_frame.as_mut() else { + return false; + }; + for row in rows { + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + let Some(target) = frame.cells.get_mut(start..end) else { + return false; + }; + target.clone_from_slice(&row.cells); + } + frame.cursor = cursor; + self.last_visible_cursor = encoded.next_last_visible_cursor; + self.last_cursor_shape = encoded.next_last_cursor_shape; + true } } @@ -373,7 +468,6 @@ fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String { // --------------------------------------------------------------------------- /// Checks if two cells are visually identical. -#[cfg(test)] fn cells_equal(a: &CellData, b: &CellData) -> bool { a.symbol == b.symbol && a.fg == b.fg @@ -444,6 +538,116 @@ fn blit_frame_to_with_cursor_memory_and_policy( ); } +fn frame_cell_index(frame: &FrameData, x: u16, y: u16) -> Option { + (x < frame.width && y < frame.height) + .then(|| usize::from(y) * usize::from(frame.width) + usize::from(x)) +} + +fn patch_cell_mut(rows: &mut [PaneSurfacePatchRow], x: u16, y: u16) -> Option<&mut CellData> { + rows.iter_mut().rev().find_map(|row| { + if row.y != y || x < row.x { + return None; + } + row.cells.get_mut(usize::from(x - row.x)) + }) +} + +fn patch_rows_overlap(rows: &[PaneSurfacePatchRow]) -> bool { + rows.iter().enumerate().any(|(index, left)| { + let left_end = left.x.saturating_add(left.cells.len() as u16); + rows[index + 1..].iter().any(|right| { + if left.y != right.y { + return false; + } + let right_end = right.x.saturating_add(right.cells.len() as u16); + left.x < right_end && right.x < left_end + }) + }) +} + +fn patch_row_fits(frame: &FrameData, row: &PaneSurfacePatchRow) -> bool { + let Ok(len) = u16::try_from(row.cells.len()) else { + return false; + }; + if row.y >= frame.height + || row.x.saturating_add(len) > frame.width + || row.cells.iter().any(|cell| cell.hyperlink.is_some()) + { + return false; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + frame + .cells + .get(start..end) + .is_some_and(|cells| cells.iter().all(|cell| cell.hyperlink.is_none())) +} + +fn blit_patch_to( + mut writer: impl Write, + frame: &FrameData, + rows: &[PaneSurfacePatchRow], + cursor: Option, + last_visible_cursor: &mut Option<(u16, u16)>, + last_cursor_shape: &mut u8, + repeat_ime_anchor: bool, + suppress_visible_cursor: bool, +) { + let _ = writer.write_all(b"\x1b[?2026h\x1b[?25l\x1b]8;;\x1b\\"); + let mut last_sgr = String::new(); + let mut active_hyperlink = None; + for row in rows { + let mut invalidated = 0usize; + let mut to_skip = 0usize; + let mut next_inline_col = None; + for (offset, cell) in row.cells.iter().enumerate() { + let col = row.x + offset as u16; + let idx = usize::from(row.y) * usize::from(frame.width) + usize::from(col); + let prev_cell = &frame.cells[idx]; + if !cell.skip && (!cells_equal(cell, prev_cell) || invalidated > 0) && to_skip == 0 { + let cursor_position = + (next_inline_col != Some(col) || invalidated > 0).then_some((col, row.y)); + write_cell( + &mut writer, + cursor_position, + cell, + &mut last_sgr, + &mut active_hyperlink, + frame, + ); + next_inline_col = (cell.symbol.is_ascii() && cell_width(cell) == 1) + .then_some(col.saturating_add(1)); + } + to_skip = cell_width(cell).saturating_sub(1); + let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell)); + invalidated = cmp::max(affected_width, invalidated).saturating_sub(1); + } + } + close_hyperlink(&mut writer, &mut active_hyperlink); + if !last_sgr.is_empty() { + let _ = writer.write_all(b"\x1b[0m"); + } + + let cursor_frame = FrameData { + cells: Vec::new(), + width: frame.width, + height: frame.height, + cursor, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let mut host_cursor = resolve_host_cursor_state(&cursor_frame, last_visible_cursor); + if suppress_visible_cursor && host_cursor.visible { + host_cursor.visible = false; + } + write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape); + let _ = writer.write_all(b"\x1b[?2026l"); + if repeat_ime_anchor { + write_ime_anchor_cursor_state(&mut writer, host_cursor); + } + let _ = writer.flush(); +} + fn blit_frame_to_with_cursor_memory_and_clear_policy( mut writer: impl Write, frame: &FrameData, @@ -1544,6 +1748,169 @@ mod tests { assert!(output.bytes().filter(|byte| *byte == b'A').count() >= 6); } + #[test] + fn retained_patch_matches_full_diff_and_updates_the_encoder_baseline() { + let previous = make_frame( + 4, + 2, + vec![ + make_cell("a", 0, 0, 0), + make_cell("b", 0, 0, 0), + make_cell("c", 0, 0, 0), + make_cell("d", 0, 0, 0), + make_cell("e", 0, 0, 0), + make_cell("f", 0, 0, 0), + make_cell("g", 0, 0, 0), + make_cell("h", 0, 0, 0), + ], + ); + let mut encoder = BlitEncoder::new(); + let initial = encoder.encode(&previous, false); + encoder.commit(previous.clone(), initial); + + let rows = vec![PaneSurfacePatchRow { + x: 0, + y: 1, + cells: vec![ + make_cell("E", 0, 0, 0), + make_cell("f", 0, 0, 0), + make_cell("G", 0, 0, 0), + make_cell("h", 0, 0, 0), + ], + }]; + let cursor = Some(CursorState { + x: 3, + y: 1, + visible: true, + shape: 2, + }); + let mut expected = previous; + expected.cells[4..8].clone_from_slice(&rows[0].cells); + expected.cursor = cursor.clone(); + + let full_diff = encoder.encode(&expected, false); + let patch = encoder + .encode_patch(&rows, cursor.clone(), false) + .expect("valid retained patch"); + assert_eq!(patch.bytes, full_diff.bytes); + assert!(encoder.commit_patch(&rows, cursor, patch)); + assert!(encoder.is_current(&expected)); + } + + #[test] + fn retained_patch_width_transition_matches_full_diff_with_following_cell() { + let previous = make_frame( + 3, + 1, + vec![ + make_cell("界", 0, 0, 0), + make_cell("z", 0, 0, 0), + make_cell("q", 0, 0, 0), + ], + ); + let mut encoder = BlitEncoder::new(); + let initial = encoder.encode(&previous, false); + encoder.commit(previous.clone(), initial); + + let rows = vec![PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![make_cell("x", 0, 0, 0), make_cell("z", 0, 0, 0)], + }]; + let mut expected = previous; + expected.cells[0..2].clone_from_slice(&rows[0].cells); + + let full_diff = encoder.encode(&expected, false); + let patch = encoder + .encode_patch(&rows, None, false) + .expect("valid retained patch"); + assert_eq!(patch.bytes, full_diff.bytes); + } + + #[test] + fn retained_patch_rejects_overlapping_rows() { + let frame = make_frame( + 3, + 1, + vec![ + make_cell("a", 0, 0, 0), + make_cell("b", 0, 0, 0), + make_cell("c", 0, 0, 0), + ], + ); + let mut encoder = BlitEncoder::new(); + let initial = encoder.encode(&frame, false); + encoder.commit(frame, initial); + let rows = vec![ + PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![make_cell("A", 0, 0, 0), make_cell("B", 0, 0, 0)], + }, + PaneSurfacePatchRow { + x: 1, + y: 0, + cells: vec![make_cell("C", 0, 0, 0)], + }, + ]; + + assert!(encoder.encode_patch(&rows, None, false).is_none()); + } + + #[test] + fn retained_patch_preserves_the_client_drawn_cursor_overlay() { + let mut previous = make_frame( + 3, + 1, + vec![ + make_cell("a", 0, 0, 0), + make_cell("b", 0, 0, 0), + make_cell("c", 0, 0, 0), + ], + ); + previous.cursor = Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 0, + }); + let previous_drawn = frame_with_drawn_cursor(previous.clone()); + let mut encoder = BlitEncoder::new(); + let initial = encoder.encode_with_suppressed_visible_cursor(&previous_drawn, false); + encoder.commit(previous_drawn, initial); + + let rows = vec![PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![ + make_cell("A", 0, 0, 0), + make_cell("b", 0, 0, 0), + make_cell("c", 0, 0, 0), + ], + }]; + let cursor = Some(CursorState { + x: 1, + y: 0, + visible: true, + shape: 0, + }); + let drawn_rows = encoder + .patch_rows_with_drawn_cursor(&rows, cursor.as_ref()) + .expect("drawn cursor patch rows"); + let mut expected = previous; + expected.cells[0..3].clone_from_slice(&rows[0].cells); + expected.cursor = cursor.clone(); + let expected = frame_with_drawn_cursor(expected); + + let full_diff = encoder.encode_with_suppressed_visible_cursor(&expected, false); + let patch = encoder + .encode_patch(&drawn_rows, cursor.clone(), true) + .expect("valid drawn cursor patch"); + assert_eq!(patch.bytes, full_diff.bytes); + assert!(encoder.commit_patch(&drawn_rows, cursor, patch)); + assert!(encoder.is_current(&expected)); + } + #[test] fn blit_frame_positions_cursor() { let frame = FrameData { diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 3680a557..b9b2eef8 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -43,24 +43,11 @@ pub enum RenderEncoding { TerminalAnsi, } -/// Keybinding profile requested by an attached app client. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientKeybindings { - /// Use the server's own keybinding config. - Server, - /// Use this attached client's normalized local `[keys]` config. - Local { keys_toml: String }, -} - -/// Client behavior requested at connection time. +/// Size of the pane surface requested by a client-owned shell. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ClientLaunchMode { - /// Full app client. - App, - /// Full app client eligible for audited local direct graphics. - AppDirectGraphics, - /// Direct terminal attach client. - TerminalAttach, +pub struct ClientSurfaceSize { + pub cols: u16, + pub rows: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -70,7 +57,7 @@ pub enum ClientKeyKind { Release, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ClientKeyCode { Backspace, Enter, @@ -92,7 +79,7 @@ pub enum ClientKeyCode { Null, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ClientMouseButton { Left, Right, @@ -111,6 +98,29 @@ pub enum ClientMouseKind { ScrollRight, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientMousePosition { + Cell { + column: u16, + row: u16, + }, + Pixels { + x: u32, + y: u32, + column: u16, + row: u16, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientMouseGeometry { + pub cols: u16, + pub rows: u16, + pub width_px: u32, + pub height_px: u32, +} + +#[cfg(any(windows, test))] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientInputEvent { Key { @@ -135,6 +145,34 @@ pub enum ClientInputEvent { FocusLost, } +/// Pane-domain input after the client has classified and consumed shell actions. +/// +/// Keys are semantic rather than outer-terminal VT bytes so the target pane can +/// encode them for the child application's negotiated keyboard protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientPaneInputEvent { + Key { + code: ClientKeyCode, + modifiers: u8, + kind: ClientKeyKind, + repeat_count: u16, + shifted_codepoint: Option, + generated_text: Option, + tracks_release: bool, + physical_key_id: Option, + }, + TextCommit(String), + Mouse { + kind: ClientMouseKind, + position: ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + }, + Paste(String), +} + +#[cfg(any(windows, test))] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientKeySource { Synthesized, @@ -147,7 +185,6 @@ pub enum ClientKeySource { } impl ClientKeyKind { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self { match kind { crossterm::event::KeyEventKind::Press => Self::Press, @@ -166,7 +203,6 @@ impl ClientKeyKind { } impl ClientKeyCode { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option { use crossterm::event::KeyCode; Some(match code { @@ -218,7 +254,6 @@ impl ClientKeyCode { } impl ClientMouseButton { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self { match button { crossterm::event::MouseButton::Left => Self::Left, @@ -237,7 +272,6 @@ impl ClientMouseButton { } impl ClientMouseKind { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option { use crossterm::event::MouseEventKind; Some(match kind { @@ -267,8 +301,74 @@ impl ClientMouseKind { } } +impl ClientPaneInputEvent { + pub(crate) fn from_terminal_key(key: crate::input::TerminalKey) -> Option { + let tracks_release = key.generated_text.is_none() || key.has_physical_identity(); + let physical_key_id = key.physical_key_id(); + Some(Self::Key { + code: ClientKeyCode::from_crossterm(key.code)?, + modifiers: key.modifiers.bits(), + kind: ClientKeyKind::from_crossterm(key.kind), + repeat_count: key.repeat_count, + shifted_codepoint: key.shifted_codepoint, + generated_text: key.generated_text, + tracks_release, + physical_key_id, + }) + } + + pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent { + match self { + Self::Key { + code, + modifiers, + kind, + repeat_count, + shifted_codepoint, + generated_text, + tracks_release, + .. + } => { + let mut key = crate::input::TerminalKey::new( + code.to_crossterm(), + crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), + ) + .with_kind(kind.to_crossterm()) + .with_repeat_count(*repeat_count) + .with_generated_text(generated_text.clone()) + .with_physical_identity_hint(*tracks_release && generated_text.is_some()); + if let Some(shifted_codepoint) = shifted_codepoint { + key = key.with_shifted_codepoint(*shifted_codepoint); + } + crate::raw_input::RawInputEvent::Key(key) + } + Self::TextCommit(text) => { + crate::raw_input::RawInputEvent::Text(crate::input::TextCommit::new(text.clone())) + } + Self::Mouse { + kind, + position, + modifiers, + .. + } => { + let (column, row) = match position { + ClientMousePosition::Cell { column, row } + | ClientMousePosition::Pixels { column, row, .. } => (*column, *row), + }; + crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: kind.to_crossterm(), + column, + row, + modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), + }) + } + Self::Paste(text) => crate::raw_input::RawInputEvent::Paste(text.clone()), + } + } +} + +#[cfg(any(windows, test))] impl ClientInputEvent { - #[cfg(any(windows, test))] pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option { match event { crossterm::event::Event::Key(key) => Some(Self::Key { @@ -341,24 +441,14 @@ impl ClientInputEvent { /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { - /// Handshake: client announces its protocol version and terminal dimensions. - Hello { - /// Protocol version the client speaks. + /// Direct terminal handshake: announces protocol version and terminal dimensions. + TerminalHello { version: u32, - /// Terminal width in columns. cols: u16, - /// Terminal height in rows. rows: u16, - /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, - /// Render encoding requested by the client. - requested_encoding: RenderEncoding, - /// Keybinding profile requested by the client. - keybindings: ClientKeybindings, - /// Whether this connection will render the full app or attach directly to a pane terminal. - launch_mode: ClientLaunchMode, + pixel_mouse: bool, }, /// Raw input bytes read from the client's stdin. @@ -369,6 +459,8 @@ pub enum ClientMessage { /// Image bytes read from the client's local clipboard for remote paste bridging. ClipboardImage { + /// Stable terminal target selected by the client that read the clipboard. + target: ClientClipboardImageTarget, /// Image file extension without a leading dot. extension: String, /// Raw image bytes. @@ -383,8 +475,10 @@ pub enum ClientMessage { rows: u16, /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. + /// Height of a terminal cell in physical pixels, or 0 when unavailable. cell_height_px: u32, + /// Whether this resize carries coherent exact geometry for SGR pixel mouse input. + pixel_mouse: bool, }, /// Graceful disconnect request. @@ -414,9 +508,6 @@ pub enum ClientMessage { modifiers: u8, }, - /// Structured input events from platform clients that do not expose Unix-style raw bytes. - InputEvents { events: Vec }, - /// Switch this connection into read-only terminal observe mode. ObserveTerminal { /// Pane, terminal, or agent target to observe. @@ -438,17 +529,120 @@ pub enum ClientMessage { success: bool, }, - /// One confirmed SGR 1016 mouse report with read-time host geometry. - InputPixels { - data: Vec, - cols: u16, - rows: u16, - width_px: u32, - height_px: u32, - }, - /// The direct command was written and flushed; terminal response timing starts now. GraphicsTransmissionStarted { transfer_id: u64, image_id: u32 }, + + /// Handshake for the client-owned shell around one pane surface. + ClientShellHello { + version: u32, + cell_width_px: u32, + cell_height_px: u32, + surface_size: ClientSurfaceSize, + pixel_mouse: bool, + direct_graphics: bool, + /// Whether the endpoint's keymap, rather than the client's, owns shell bindings. + endpoint_keybindings: bool, + /// Whether this client wants shell mouse capture even without pane demand. + mouse_capture: bool, + }, + + /// Resize the pane viewport of a client-owned shell. + ClientShellResize { + cell_width_px: u32, + cell_height_px: u32, + surface_size: ClientSurfaceSize, + /// Whether this resize carries coherent exact geometry for SGR pixel mouse input. + pixel_mouse: bool, + }, + + /// Deliver client-classified semantic input directly to a stable pane target. + ClientShellPaneInput { + pane_id: String, + events: Vec, + }, + + /// Deliver client-classified semantic input to the active popup terminal. + ClientShellPopupInput { + terminal_id: String, + events: Vec, + }, + + /// Invoke one endpoint operation through this client shell's selected connection. + ClientShellEndpointRequest { boot_id: String, request: String }, + + /// Deliver one structured mouse event to a directly attached terminal. + AttachMouse { + kind: ClientMouseKind, + position: ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + }, + + /// Publish one host terminal color or appearance update observed by a client-owned shell. + ClientShellHostTheme { update: ClientHostThemeUpdate }, + + /// Publish whether the outer terminal containing a client shell has focus. + ClientShellFocus { focused: bool }, + + /// Update this client's shell mouse-capture preference after config reload. + ClientShellMouseCapture { enabled: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientHostColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl From for ClientHostColor { + fn from(color: crate::terminal_theme::RgbColor) -> Self { + Self { + r: color.r, + g: color.g, + b: color.b, + } + } +} + +impl From for crate::terminal_theme::RgbColor { + fn from(color: ClientHostColor) -> Self { + Self { + r: color.r, + g: color.g, + b: color.b, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostDefaultColorKind { + Foreground, + Background, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostAppearance { + Dark, + Light, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientHostThemeUpdate { + DefaultColor { + kind: ClientHostDefaultColorKind, + color: ClientHostColor, + }, + PaletteColors(Vec<(u8, ClientHostColor)>), + Appearance(ClientHostAppearance), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientClipboardImageTarget { + DirectTerminal, + Pane(String), + Popup(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -600,11 +794,37 @@ impl FrameData { } } + pub(crate) fn replace_from_ratatui_buffer_preserving_effects( + &mut self, + buffer: &ratatui::buffer::Buffer, + cursor: Option, + ) { + let width = self.width; + let hyperlinks = if width == 0 { + Vec::new() + } else { + self.cells + .iter() + .enumerate() + .filter_map(|(index, cell)| { + let uri = self.hyperlinks.get(cell.hyperlink? as usize)?; + let x = u16::try_from(index % usize::from(width)).ok()?; + let y = u16::try_from(index / usize::from(width)).ok()?; + Some(((x, y), cell.symbol.clone(), uri.clone())) + }) + .collect::>() + }; + let graphics = std::mem::take(&mut self.graphics); + let mut replacement = + Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &hyperlinks); + replacement.graphics = graphics; + *self = replacement; + } + /// Reconstructs a ratatui `Buffer` from this frame data. /// /// Returns `None` if the cells vector length doesn't match `width * height`. - #[cfg(test)] - pub fn to_ratatui_buffer(&self) -> Option { + pub(crate) fn to_ratatui_buffer(&self) -> Option { let expected = (self.width as usize) * (self.height as usize); if self.cells.len() != expected { return None; @@ -630,6 +850,361 @@ impl FrameData { } } +/// Initial resource projection used by the experimental client-owned shell. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellSnapshot { + /// Changes whenever the endpoint process restarts. + pub boot_id: String, + /// Monotonic replacement revision within one endpoint boot. + pub revision: u64, + /// Endpoint startup/reload config warning, filtered for client-owned keybindings. + pub config_diagnostic: Option, + /// Unseen announcement owned and persisted by this endpoint. + pub product_announcement: Option, + /// Future-version update advertised by the endpoint. + pub update_available: Option, + /// Endpoint-specific command shown in update instructions. + pub update_install_command: String, + /// Endpoint's normalized built-in keybindings, used only when a remote client selects server bindings. + pub server_keybindings_toml: Option, + /// Whether the endpoint has a What's New entry, even if its body is unavailable. + pub latest_release_notes_available: bool, + /// Whether endpoint-owned integration assets need an update. + pub integration_updates_available: bool, + /// Endpoint-owned base directory used for new linked worktree checkouts. + pub worktree_directory: String, + /// Cached endpoint-owned notes used by the client-rendered overlay. + pub release_notes: Option, + pub focused_workspace_id: Option, + pub focused_tab_id: Option, + pub focused_pane_id: Option, + pub tab_bar_right: Vec, + pub tab_bar_right_separator: String, + pub agent_view_label: Option, + pub agent_order: Vec, + pub workspaces: Vec, + pub tabs: Vec, + pub panes: Vec, + pub agents: Vec, + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellProductAnnouncement { + pub version: String, + pub id: String, + pub title: String, + pub body: String, + pub preview: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellReleaseNotes { + pub version: String, + pub body: String, + pub preview: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientShellCommandAction { + Shell, + Pane, + Popup, + PluginAction, +} + +impl From for ClientShellCommandAction { + fn from(action: crate::config::CustomCommandAction) -> Self { + match action { + crate::config::CustomCommandAction::Shell => Self::Shell, + crate::config::CustomCommandAction::Pane => Self::Pane, + crate::config::CustomCommandAction::Popup => Self::Popup, + crate::config::CustomCommandAction::PluginAction => Self::PluginAction, + } + } +} + +impl From for crate::config::CustomCommandAction { + fn from(action: ClientShellCommandAction) -> Self { + match action { + ClientShellCommandAction::Shell => Self::Shell, + ClientShellCommandAction::Pane => Self::Pane, + ClientShellCommandAction::Popup => Self::Popup, + ClientShellCommandAction::PluginAction => Self::PluginAction, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellCommand { + pub command_id: String, + pub binding_label: String, + pub binding_labels: Vec, + pub action: ClientShellCommandAction, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellTabStatusSegment { + pub text: String, + pub accent: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellWorkspace { + pub workspace_id: String, + pub active_tab_id: String, + pub new_workspace_cwd: String, + pub number: usize, + pub label: String, + pub custom_label: bool, + pub branch: Option, + pub git_ahead_behind: Option<(usize, usize)>, + pub tokens: Vec<(String, String)>, + pub worktree: Option, + pub focused: bool, + pub agent_status: crate::api::schema::AgentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellWorktree { + pub key: String, + pub label: String, + pub is_linked_worktree: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellTab { + pub tab_id: String, + pub workspace_id: String, + pub number: usize, + pub label: String, + pub custom_label: bool, + pub zoomed: bool, + pub focused: bool, + pub agent_status: crate::api::schema::AgentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellPane { + pub pane_id: String, + pub workspace_id: String, + pub tab_id: String, + pub label: Option, + pub cwd: Option, + pub foreground_cwd: Option, + pub focused: bool, + pub right_click_passthrough: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellAgent { + pub pane_id: String, + pub workspace_id: String, + pub tab_id: String, + pub name: Option, + pub display_agent: Option, + pub agent: Option, + pub title: Option, + pub terminal_title: Option, + pub terminal_title_stripped: Option, + pub agent_status: crate::api::schema::AgentStatus, + pub state_change_seq: u64, + pub state_labels: Vec<(String, String)>, + pub tokens: Vec<(String, String)>, + pub focused: bool, +} + +/// Origin-relative geometry for one pane in a rendered pane surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfacePane { + pub pane_id: String, + pub content_revision: u64, + pub rect: SurfaceRect, + pub inner_rect: SurfaceRect, + pub scrollbar_rect: Option, + pub scroll: Option, + pub focused: bool, + pub mouse_reporting: bool, + pub sgr_pixel_mouse: bool, + pub alternate_screen_active: bool, + pub pixel_width: u32, + pub pixel_height: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfaceScrollMetrics { + pub offset_from_bottom: u64, + pub max_offset_from_bottom: u64, + pub viewport_rows: u64, +} + +/// One draggable BSP split handle relative to a pane surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfaceSplit { + pub direction: PaneSurfaceSplitDirection, + pub pos: u16, + pub area: SurfaceRect, + pub hit_rect: SurfaceRect, + pub path: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaneSurfaceSplitDirection { + Horizontal, + Vertical, +} + +/// Wire-safe rectangle relative to a pane surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceRect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl From for SurfaceRect { + fn from(rect: ratatui::layout::Rect) -> Self { + Self { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum SurfaceGraphicsTarget { + Pane { pane_id: String }, + Popup { terminal_id: String }, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum SurfaceGraphicsSource { + Terminal { + target: SurfaceGraphicsTarget, + image_id: u32, + }, + PaneLayer { + pane_id: String, + layer_id: String, + }, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum SurfaceGraphicsFormat { + Rgb, + Rgba, + Png, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceGraphicsAssetKey { + pub source: SurfaceGraphicsSource, + pub image_width: u32, + pub image_height: u32, + pub format: SurfaceGraphicsFormat, + pub data_len: u64, + pub data_fingerprint: u64, +} + +/// Image bytes newly needed by this connection's complete desired scene. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceGraphicsAsset { + pub key: SurfaceGraphicsAssetKey, + pub data: Vec, +} + +/// One already-clipped desired placement relative to its target surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceGraphicsPlacement { + pub asset: SurfaceGraphicsAssetKey, + pub logical_placement_id: u32, + pub x: u16, + pub y: u16, + pub cols: u32, + pub rows: u32, + pub source_x: u32, + pub source_y: u32, + pub source_width: u32, + pub source_height: u32, + pub x_offset: u32, + pub y_offset: u32, + pub z: i32, + pub scrollback_offset: u32, +} + +/// Complete desired placements plus only the image bytes not already sent for +/// the current live scene on this connection. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceGraphicsScene { + pub assets: Vec, + pub placements: Vec, + /// Direct-uploaded assets that remain live for this client even while their + /// pane is outside the selected scene. + pub retained_assets: Vec, +} + +/// One server-rendered active-tab surface without sidebar, tab bar, or overlays. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfaceFrame { + /// Endpoint process identity that produced this surface. + pub boot_id: String, + /// Projection revision whose focused IDs and topology produced this surface. + pub projection_revision: u64, + /// Monotonic revision for full surfaces and incremental patches on one connection. + pub surface_revision: u64, + pub frame: FrameData, + pub panes: Vec, + pub splits: Vec, + pub popup: Option>, + pub graphics: SurfaceGraphicsScene, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientShellPopupSize { + Cells(u16), + Percent(u8), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfacePatchRow { + /// Origin-relative surface column where this changed cell span starts. + pub x: u16, + /// Origin-relative surface row. + pub y: u16, + pub cells: Vec, +} + +/// Incremental terminal-cell update against one committed complete pane surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSurfacePatch { + pub boot_id: String, + pub projection_revision: u64, + pub base_surface_revision: u64, + pub surface_revision: u64, + pub rows: Vec, + /// Updated metadata for panes whose terminal content changed. + pub panes: Vec, + /// Final cursor relative to the pane surface. + pub cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientShellPopupSurface { + pub terminal_id: String, + pub title: String, + pub width: Option, + pub height: Option, + pub frame: FrameData, + pub mouse_reporting: bool, + pub sgr_pixel_mouse: bool, + pub pixel_width: u32, + pub pixel_height: u32, +} + /// Terminal ANSI bytes encoded by the server for network-efficient clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TerminalFrame { @@ -656,6 +1231,35 @@ pub enum NotifyKind { SystemToast, } +/// A client-rendered notification category. The server reports the semantic +/// event; each connected shell client chooses how (or whether) to present it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SemanticNotificationKind { + NeedsAttention, + Finished, + UpdateInstalled, + Custom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SemanticNotificationSound { + Done, + Request, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticNotification { + pub kind: SemanticNotificationKind, + pub title: String, + pub body: Option, + pub sound: Option, + pub agent: Option, + pub workspace_id: Option, + pub tab_id: Option, + pub pane_id: Option, + pub position: Option, +} + /// Messages sent from the server to the client over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ServerMessage { @@ -670,9 +1274,6 @@ pub enum ServerMessage { error: Option, }, - /// A rendered frame to be displayed by a semantic-frame client. - Frame(FrameData), - /// Terminal bytes to write directly for a terminal-ANSI client. Terminal(TerminalFrame), @@ -721,20 +1322,6 @@ pub enum ServerMessage { sgr_pixels: bool, }, - /// Whether the focused terminal requests Kitty report-all keyboard input. - KittyKeyboardReportAll { - /// True only while the focused pane requests `REPORT_ALL_KEYS_AS_ESCAPE_CODES`. - enabled: bool, - }, - - /// Apply the prefix-mode ASCII input-source change on the foreground client. - /// `active = true` → switch to an ASCII-capable source (saving the current one); - /// `active = false` → restore the saved source. - PrefixInputSource { - /// Whether the ASCII input source should be active. - active: bool, - }, - /// Ring the foreground client's outer terminal for pane-originated BEL characters. TerminalBell { /// Number of BEL characters parsed from one PTY read. @@ -749,10 +1336,46 @@ pub enum ServerMessage { transfer_id: u64, leading: Vec, control: String, + /// ClientShell upload identity. `None` targets a direct terminal client. + surface_asset: Option, }, /// Suppress a direct command that expired before terminal delivery. GraphicsTransmissionRetired { transfer_id: u64, image_id: u32 }, + + /// Initial metadata for a client-owned shell. + ClientShellSnapshot(Box), + + /// Active-tab pane content rendered at a client-requested origin-relative size. + PaneSurface(PaneSurfaceFrame), + + /// Ephemeral semantic notification delivered over the private control lane. + /// It is sent only to currently connected client-rendered shells. + SemanticNotification(SemanticNotification), + + /// Immediate endpoint error that the client-rendered shell must show regardless of notification policy. + ClientShellError { message: String }, + + /// Exact Kitty keyboard flags requested by a directly attached terminal. + /// Zero restores the host terminal's previous keyboard mode. + DirectTerminalKeyboardProtocol { + flags: u16, + modify_other_keys_level: u8, + }, + + /// Whether the focused pane or popup needs the shell host to report every key. + ClientShellKeyboardReportAll { enabled: bool }, + + /// One ordered chunk of the final response to an endpoint operation. + ClientShellEndpointResponseChunk { + boot_id: String, + request_id: String, + final_chunk: bool, + data: Vec, + }, + + /// Incremental terminal-cell update for a previously committed pane surface. + PaneSurfacePatch(PaneSurfacePatch), } // --------------------------------------------------------------------------- @@ -792,7 +1415,6 @@ pub(crate) fn color_to_u32(color: ratatui::style::Color) -> u32 { } /// Converts a packed u32 back to a ratatui `Color`. -#[cfg(test)] fn u32_to_color(val: u32) -> ratatui::style::Color { match val >> 24 { 0x00 => match val & 0xFF { @@ -847,7 +1469,6 @@ pub(crate) fn modifier_with_underline_style( } /// Converts a u16 back to a ratatui `Modifier`. -#[cfg(test)] fn u16_to_modifier(val: u16) -> ratatui::style::Modifier { ratatui::style::Modifier::from_bits_truncate(val & !UNDERLINE_STYLE_MASK) } @@ -1033,15 +1654,45 @@ mod tests { #[test] fn client_hello_roundtrip() { - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_shell_hello_roundtrip() { + let msg = ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn client_shell_resize_roundtrip() { + let msg = ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 74, rows: 29 }, + pixel_mouse: true, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -1061,7 +1712,7 @@ mod tests { } #[test] - fn client_message_wire_tags_preserve_protocol_15_order() { + fn client_message_wire_tags_reflect_current_order() { fn tag(msg: &ClientMessage) -> u8 { *bincode::serde::encode_to_vec(msg, bincode::config::standard()) .unwrap() @@ -1070,21 +1721,33 @@ mod tests { } assert_eq!( - tag(&ClientMessage::Hello { + tag(&ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }), 0 ); + assert_eq!( + tag(&ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + }), + 11 + ); assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1); assert_eq!( tag(&ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: Vec::new(), }), @@ -1096,6 +1759,7 @@ mod tests { rows: 24, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: false, }), 3 ); @@ -1118,162 +1782,181 @@ mod tests { }), 6 ); - assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7); assert_eq!( tag(&ClientMessage::ObserveTerminal { target: "w1:p1".to_owned(), }), - 8 + 7 ); assert_eq!( tag(&ClientMessage::ControlTerminal { target: "w1:p1".to_owned(), takeover: false, }), - 9 + 8 ); - } - - #[test] - fn client_input_events_roundtrip() { - let msg = ClientMessage::InputEvents { - events: vec![ - ClientInputEvent::Key { - code: ClientKeyCode::Char('N'), - modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), - kind: ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, - kind: ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[127;1u".to_vec(), - }, - }, - ClientInputEvent::Key { - code: ClientKeyCode::Esc, - modifiers: 0, - kind: ClientKeyKind::Release, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { - record: crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }, - }, - }, - ClientInputEvent::TextCommit("你🙂".to_owned()), - ClientInputEvent::Mouse { - kind: ClientMouseKind::Down(ClientMouseButton::Left), - column: 3, - row: 4, - modifiers: 0, - }, - ], - }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - // Freeze the protocol 20 input envelope before it is published. assert_eq!( - encoded, - vec![ - 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49, - 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, - 130, 2, 0, 0, 3, 4, 0, - ] + tag(&ClientMessage::AttachMouse { + kind: ClientMouseKind::Down(ClientMouseButton::Left), + position: ClientMousePosition::Cell { column: 10, row: 5 }, + geometry: None, + modifiers: 0, + lines: 1, + }), + 16 ); + assert_eq!( + tag(&ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::Appearance(ClientHostAppearance::Dark), + }), + 17 + ); + assert_eq!(tag(&ClientMessage::ClientShellFocus { focused: true }), 18); + assert_eq!( + tag(&ClientMessage::ClientShellMouseCapture { enabled: true }), + 19 + ); + } + + #[test] + fn client_shell_pane_input_roundtrips_semantic_keys() { + let message = ClientMessage::ClientShellPaneInput { + pane_id: "w1:p2".into(), + events: vec![ClientPaneInputEvent::Key { + code: ClientKeyCode::Char('l'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: Some('L' as u32), + generated_text: None, + tracks_release: true, + physical_key_id: None, + }], + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode targeted semantic input"); let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode targeted semantic input"); + assert_eq!(decoded, message); + let ClientMessage::ClientShellPaneInput { events, .. } = decoded else { + panic!("expected targeted semantic input"); + }; + let crate::raw_input::RawInputEvent::Key(key) = events[0].to_raw_input_event() else { + panic!("expected semantic key"); + }; + assert_eq!(key.shifted_codepoint, Some('L' as u32)); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); } #[test] - fn wire_release_cannot_restore_a_grouped_repeat_count() { - let event = ClientInputEvent::Key { - code: ClientKeyCode::Esc, - modifiers: 0, - kind: ClientKeyKind::Release, - repeat_count: 3, - generated_text: Some("ignored".to_owned()), - source: ClientKeySource::Synthesized, + fn client_shell_key_roundtrip_preserves_physical_generated_text_encoding() { + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::SHIFT, + ) + .with_generated_text(Some("/".into())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: '/' as u16, + control_key_state: 0, + }); + let event = ClientPaneInputEvent::from_terminal_key(key).expect("semantic pane key"); + assert!(matches!( + event, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + )); + let crate::raw_input::RawInputEvent::Key(roundtripped) = event.to_raw_input_event() else { + panic!("pane key should remain a key"); }; - match event.to_raw_input_event() { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); - assert_eq!(key.repeat_count, 1); - assert_eq!(key.generated_text, None); - } - other => panic!("expected key event, got {other:?}"), - } + assert!(roundtripped.has_physical_identity()); + let encoded = crate::input::encode_terminal_key( + roundtripped, + crate::input::KeyboardProtocol::Kitty { flags: 8 }, + ); + assert_ne!(encoded, b"/"); + assert!(encoded.starts_with(b"\x1b[")); } #[test] - fn client_input_events_convert_to_raw_keys() { - let record = crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - virtual_key_code: 78, - virtual_scan_code: 49, - unicode: 78, - control_key_state: 16, - }; - let shifted = ClientInputEvent::Key { - code: ClientKeyCode::Char('N'), - modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), - kind: ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: ClientKeySource::WindowsConsole { record }, - } - .to_raw_input_event(); - match shifted { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.code, crossterm::event::KeyCode::Char('N')); - assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT); - assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); - assert_eq!( - key.windows_record().map(|record| record.key_down), - Some(false) - ); - } - other => panic!("expected shifted key event, got {other:?}"), - } - - let backspace = ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, - kind: ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - } - .to_raw_input_event(); - match backspace { - crate::raw_input::RawInputEvent::Key(key) => { - assert_eq!(key.code, crossterm::event::KeyCode::Backspace); - assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty()); - assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); - } - other => panic!("expected backspace key event, got {other:?}"), - } + fn client_shell_focus_roundtrip() { + let message = ClientMessage::ClientShellFocus { focused: false }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode client shell focus"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode client shell focus"); + assert_eq!(decoded, message); } + #[test] + fn client_shell_mouse_capture_roundtrip() { + let message = ClientMessage::ClientShellMouseCapture { enabled: false }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode client shell mouse capture"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode client shell mouse capture"); + assert_eq!(decoded, message); + } + + #[test] + fn client_shell_host_theme_roundtrip() { + let message = ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::PaletteColors(vec![( + 4, + ClientHostColor { + r: 10, + g: 20, + b: 30, + }, + )]), + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()) + .expect("encode host theme update"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode host theme update"); + assert_eq!(decoded, message); + } + + #[test] + fn client_shell_endpoint_messages_roundtrip() { + let request = ClientMessage::ClientShellEndpointRequest { + boot_id: "boot-a".into(), + request: r#"{"id":"request-a","method":"session.snapshot","params":{}}"#.into(), + }; + let encoded = bincode::serde::encode_to_vec(&request, bincode::config::standard()) + .expect("encode endpoint request"); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode endpoint request"); + assert_eq!(decoded, request); + + let response = ServerMessage::ClientShellEndpointResponseChunk { + boot_id: "boot-a".into(), + request_id: "request-a".into(), + final_chunk: true, + data: br#"{"id":"request-a","result":{"type":"ok"}}"#.to_vec(), + }; + let encoded = bincode::serde::encode_to_vec(&response, bincode::config::standard()) + .expect("encode endpoint response"); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()) + .expect("decode endpoint response"); + assert_eq!(decoded, response); + } #[test] fn client_clipboard_image_roundtrip() { let msg = ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::Pane("w1:p1".into()), extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }; @@ -1307,6 +1990,7 @@ mod tests { rows: 24, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: true, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -1467,20 +2151,148 @@ mod tests { hyperlinks: vec!["https://example.com".to_owned()], graphics: Vec::new(), }; - let msg = ServerMessage::Frame(frame.clone()); + let msg = ServerMessage::PaneSurface(PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: frame.clone(), + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene::default(), + }); let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); match decoded { - ServerMessage::Frame(frame) => { - assert_eq!(frame.cells[2].hyperlink, Some(0)); - assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); + ServerMessage::PaneSurface(surface) => { + assert_eq!(surface.frame.cells[2].hyperlink, Some(0)); + assert_eq!( + surface.frame.hyperlinks, + vec!["https://example.com".to_owned()] + ); } - other => panic!("expected frame, got {other:?}"), + other => panic!("expected pane surface, got {other:?}"), } } + #[test] + fn pane_surface_patch_roundtrip() { + let msg = ServerMessage::PaneSurfacePatch(PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 3, + base_surface_revision: 7, + surface_revision: 8, + rows: vec![PaneSurfacePatchRow { + x: 2, + y: 4, + cells: vec![CellData { + symbol: "x".into(), + fg: 1, + bg: 2, + modifier: 3, + skip: false, + hyperlink: None, + }], + }], + panes: Vec::new(), + cursor: Some(CursorState { + x: 2, + y: 4, + visible: true, + shape: 2, + }), + }); + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn client_shell_snapshot_roundtrip() { + let msg = ServerMessage::ClientShellSnapshot(Box::new(ClientShellSnapshot { + boot_id: "boot-1".into(), + revision: 1, + config_diagnostic: Some("endpoint config warning".into()), + product_announcement: Some(ClientShellProductAnnouncement { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "### New\n- Client-owned chrome".into(), + preview: false, + }), + update_available: Some("0.8.3".into()), + update_install_command: "herdr update".into(), + server_keybindings_toml: Some("[keys]\nprefix = \"ctrl+a\"\n".into()), + latest_release_notes_available: true, + integration_updates_available: true, + worktree_directory: "/tmp/herdr-worktrees".into(), + release_notes: Some(ClientShellReleaseNotes { + version: "0.8.3".into(), + body: "### New\n- Update ready".into(), + preview: true, + }), + focused_workspace_id: Some("w1".into()), + focused_tab_id: Some("w1:t1".into()), + focused_pane_id: Some("w1:p1".into()), + tab_bar_right: vec![ClientShellTabStatusSegment { + text: "host".into(), + accent: false, + }], + tab_bar_right_separator: " · ".into(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: vec![ClientShellWorkspace { + workspace_id: "w1".into(), + active_tab_id: "w1:t1".into(), + new_workspace_cwd: "/tmp".into(), + number: 1, + label: "shell".into(), + custom_label: false, + branch: Some("main".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: None, + focused: true, + agent_status: crate::api::schema::AgentStatus::Idle, + }], + tabs: vec![ClientShellTab { + tab_id: "w1:t1".into(), + workspace_id: "w1".into(), + number: 1, + label: "main".into(), + custom_label: true, + zoomed: false, + focused: true, + agent_status: crate::api::schema::AgentStatus::Idle, + }], + panes: vec![ClientShellPane { + pane_id: "w1:p1".into(), + workspace_id: "w1".into(), + tab_id: "w1:t1".into(), + label: None, + cwd: Some("/repo".into()), + foreground_cwd: Some("/repo".into()), + focused: true, + right_click_passthrough: false, + }], + agents: Vec::new(), + commands: vec![ClientShellCommand { + command_id: "cmd_0123456789abcdef0123456789abcdef".into(), + binding_label: "prefix+z".into(), + binding_labels: vec!["prefix+z".into()], + action: ClientShellCommandAction::Shell, + description: Some("deploy".into()), + }], + })); + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + #[test] fn server_shutdown_roundtrip() { let msg = ServerMessage::ServerShutdown { @@ -1492,6 +2304,25 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn semantic_notification_roundtrip() { + let msg = ServerMessage::SemanticNotification(SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: Some("repo · 1".into()), + sound: Some(SemanticNotificationSound::Request), + agent: Some("codex".into()), + workspace_id: Some("w1".into()), + tab_id: Some("w1:t1".into()), + pane_id: Some("w1:p1".into()), + position: Some(crate::config::ToastHerdrPosition::TopRight), + }); + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + #[test] fn server_notify_roundtrip() { for kind in [ @@ -1581,8 +2412,20 @@ mod tests { } #[test] - fn server_kitty_keyboard_report_all_roundtrip() { - let msg = ServerMessage::KittyKeyboardReportAll { enabled: true }; + fn client_shell_keyboard_report_all_roundtrip() { + let msg = ServerMessage::ClientShellKeyboardReportAll { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn direct_terminal_keyboard_mode_roundtrip() { + let msg = ServerMessage::DirectTerminalKeyboardProtocol { + flags: 15, + modify_other_keys_level: 1, + }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); @@ -1608,34 +2451,12 @@ mod tests { transfer_id: 7, leading: b"\x1b[2;3H".to_vec(), control: "a=T,f=32,i=42,q=0".into(), + surface_asset: None, }; let encoded = bincode::serde::encode_to_vec(&server, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(server, decoded); - - let pixels = ClientMessage::InputPixels { - data: b"\x1b[<35;321;241M".to_vec(), - cols: 80, - rows: 24, - width_px: 800, - height_px: 480, - }; - let encoded = bincode::serde::encode_to_vec(&pixels, bincode::config::standard()).unwrap(); - let (decoded, _): (ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(pixels, decoded); - } - - #[test] - fn server_prefix_input_source_roundtrip() { - for active in [true, false] { - let msg = ServerMessage::PrefixInputSource { active }; - let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); - let (decoded, _): (ServerMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - assert_eq!(msg, decoded); - } } #[test] @@ -1651,15 +2472,13 @@ mod tests { #[test] fn framing_small_message_roundtrip() { - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1669,7 +2488,7 @@ mod tests { #[test] fn framing_large_payload_roundtrip() { - // Create a Frame message that is ≥128 KB. + // Create a pane-surface message that is ≥128 KB. // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding. // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB. let width: u16 = 200; @@ -1702,7 +2521,16 @@ mod tests { hyperlinks: Vec::new(), graphics: Vec::new(), }; - let msg = ServerMessage::Frame(frame); + let msg = ServerMessage::PaneSurface(PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame, + panes: Vec::new(), + splits: Vec::new(), + popup: None, + graphics: SurfaceGraphicsScene::default(), + }); let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1725,20 +2553,19 @@ mod tests { for i in 0..150u32 { let msg = match i % 5 { - 0 => ClientMessage::Hello { + 0 => ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: i % 2 == 0, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], }, 2 => ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], }, @@ -1747,6 +2574,7 @@ mod tests { rows: (30 + (i % 10) as u16), cell_width_px: 8, cell_height_px: 16, + pixel_mouse: i % 2 == 0, }, 4 => ClientMessage::Detach, _ => unreachable!(), @@ -2161,15 +2989,13 @@ mod tests { #[test] fn read_message_accepts_exact_payload() { // A normally-framed message should decode without error. - let msg = ClientMessage::Hello { + let msg = ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: false, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -2197,20 +3023,19 @@ mod tests { let (mut a, mut b) = UnixStream::pair().expect("socketpair"); let messages = vec![ - ClientMessage::Hello { + ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 200, rows: 60, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::SemanticFrame, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, }, ClientMessage::Input { data: b"hello world".to_vec(), }, ClientMessage::ClipboardImage { + target: ClientClipboardImageTarget::DirectTerminal, extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }, @@ -2219,6 +3044,7 @@ mod tests { rows: 30, cell_width_px: 8, cell_height_px: 16, + pixel_mouse: true, }, ClientMessage::Detach, ]; diff --git a/src/raw_input.rs b/src/raw_input.rs index 5f750ca8..3f2e102e 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -1,88 +1,10 @@ -use std::io::Read; - use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; /// Parse raw terminal input bytes into a list of `RawInputEvent`s. /// -/// This is used by the headless server to route client input through the -/// same parsing pipeline that the monolithic binary uses for stdin. -/// Incomplete sequences at the end of the buffer are flushed as best-effort -/// (same logic as the live input reader). -#[allow(dead_code)] -pub fn parse_raw_input_bytes(data: &[u8]) -> Vec { - // Delegate to the sync version which actually works. - parse_raw_input_bytes_sync(data) -} - -/// A raw input event paired with the byte range it consumed from the original buffer. -#[cfg(test)] -#[derive(Debug)] -pub struct RawInputEventWithRange { - /// The parsed event. - pub event: RawInputEvent, - /// Byte offset where this event starts in the original buffer. - pub start: usize, - /// Number of bytes this event consumed from the original buffer. - /// For events generated from flushed incomplete bytes, `len` may be 0 - /// (synthetic events that don't map to original bytes). - pub len: usize, -} - -/// Parse raw terminal input bytes into a list of `RawInputEventWithRange`s (synchronous version). -/// -/// Unlike `parse_raw_input_bytes_sync`, this preserves the byte offset for each -/// event, allowing callers to write only the specific bytes for each event -/// instead of the entire input buffer. -#[cfg(test)] -pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec { - let mut buffer = data.to_vec(); - let mut events = Vec::new(); - let mut offset = 0usize; - - while let Some((event, consumed)) = extract_one_event(&buffer) { - buffer.drain(..consumed); - events.push(RawInputEventWithRange { - event, - start: offset, - len: consumed, - }); - offset += consumed; - } - - // Flush remaining incomplete bytes. - if !buffer.is_empty() { - if buffer.as_slice() == [ESC] { - events.push(RawInputEventWithRange { - event: RawInputEvent::Key( - TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) - .with_vt_bytes(vec![ESC]), - ), - start: offset, - len: 1, - }); - } else if matches!( - control_string(&buffer), - Some(ControlString::Incomplete { .. }) - ) { - return events; - } else if let Ok(text) = std::str::from_utf8(&buffer) { - if let Some(key) = parse_terminal_key_sequence(text) { - events.push(RawInputEventWithRange { - event: RawInputEvent::Key(key.with_text_commit().with_vt_bytes(buffer.clone())), - start: offset, - len: buffer.len(), - }); - } - } - } - - events -} - -/// Parse raw terminal input bytes into a list of `RawInputEvent`s (synchronous version). -/// -/// Unlike `parse_raw_input_bytes`, this directly extracts events without -/// going through a channel, making it suitable for synchronous use. +/// This directly extracts events without going through a channel, making it +/// suitable for synchronous use. +#[cfg(any(unix, test))] pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec { let mut framer = RawInputFramer::default(); let mut events = framer.push(data); @@ -90,10 +12,6 @@ pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec { events } -#[cfg(unix)] -use std::os::fd::AsRawFd; -use tokio::sync::mpsc; - use crate::input::{parse_terminal_key_sequence, TerminalKey, TextCommit}; use crate::terminal_theme::{ parse_default_color_response, parse_palette_color_response, DefaultColorKind, HostAppearance, @@ -101,7 +19,9 @@ use crate::terminal_theme::{ }; const ESC: u8 = 0x1b; +#[cfg(any(unix, test))] pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10; +#[cfg(any(unix, test))] pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150; pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; @@ -157,6 +77,7 @@ pub(crate) struct RawInputFramer { } impl RawInputFramer { + #[cfg(any(windows, test))] pub(crate) fn for_host_input() -> Self { Self { byte_framer: RawInputByteFramer::for_host_input(), @@ -167,27 +88,11 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.push(data)) } - pub(crate) fn host_color_query_sent(&mut self) { - self.byte_framer.host_color_query_sent(); - } - - pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { - self.byte_framer.enable_host_color_scheme_change_tracking(); - } - - #[cfg(any(not(windows), test))] - pub(crate) fn enable_host_appearance_query_on_focus(&mut self) { - self.byte_framer.enable_host_appearance_query_on_focus(); - } - + #[cfg(any(windows, all(test, not(target_os = "macos"))))] pub(crate) fn has_pending_input(&self) -> bool { self.byte_framer.has_pending_input() } - pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { - self.byte_framer.has_pending_incomplete_mouse_sequence() - } - #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.byte_framer.has_pending_bracketed_paste() @@ -281,6 +186,7 @@ impl RawInputByteFramer { || self.host_appearance_reply_awaited } + #[cfg(any(unix, test))] pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { self.host_color_scheme_change_tracking = true; } @@ -301,6 +207,7 @@ impl RawInputByteFramer { self.buffer.as_slice() == [ESC] } + #[cfg(any(unix, test))] pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { starts_with_incomplete_sgr_mouse_sequence(&self.buffer) || starts_with_incomplete_default_mouse_sequence(&self.buffer) @@ -612,6 +519,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> } } +#[cfg(any(unix, test))] pub(crate) fn events_require_host_surface_redraw( events: &[RawInputEvent], redraw_on_focus_gained: bool, @@ -636,191 +544,6 @@ pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) .any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_))) } -fn input_flush_timeout_ms(framer: &RawInputFramer) -> i32 { - if framer.has_pending_incomplete_mouse_sequence() { - MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS - } else { - RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS - } -} - -pub fn spawn_input_reader() -> mpsc::Receiver { - let (tx, rx) = mpsc::channel(256); - - std::thread::spawn(move || { - let stdin = std::io::stdin(); - let mut reader = stdin.lock(); - let mut scratch = [0u8; 1024]; - let mut framer = RawInputFramer::for_host_input(); - framer.host_color_query_sent(); - framer.enable_host_color_scheme_change_tracking(); - #[cfg(not(windows))] - framer.enable_host_appearance_query_on_focus(); - let mut pending_palette = Vec::new(); - - loop { - match reader.read(&mut scratch) { - Ok(0) => break, - Ok(n) => { - send_raw_input_events(framer.push(&scratch[..n]), &tx, &mut pending_palette); - - if stdin_read_ready(&reader, input_flush_timeout_ms(&framer)) == Some(false) { - let had_pending = framer.has_pending_input(); - let events = framer.flush_timeout(); - let held_escape = had_pending && events.is_empty(); - send_raw_input_events(events, &tx, &mut pending_palette); - flush_host_palette_events(&tx, &mut pending_palette); - if held_escape - && stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) - == Some(false) - { - send_raw_input_events( - framer.flush_timeout(), - &tx, - &mut pending_palette, - ); - flush_host_palette_events(&tx, &mut pending_palette); - } - } - } - Err(_) => break, - } - } - }); - - rx -} - -fn send_raw_input_events( - events: Vec, - tx: &mpsc::Sender, - pending_palette: &mut Vec<(u8, RgbColor)>, -) { - for event in events { - match event { - RawInputEvent::HostPaletteColors { colors } => { - pending_palette.extend(colors); - if pending_palette.len() == 256 { - flush_host_palette_events(tx, pending_palette); - } - } - event @ RawInputEvent::HostDefaultColor { .. } => { - let _ = tx.blocking_send(event); - } - event => { - flush_host_palette_events(tx, pending_palette); - let _ = tx.blocking_send(event); - } - } - } -} - -fn flush_host_palette_events( - tx: &mpsc::Sender, - pending_palette: &mut Vec<(u8, RgbColor)>, -) { - if pending_palette.is_empty() { - return; - } - let colors = std::mem::take(pending_palette); - let _ = tx.blocking_send(RawInputEvent::HostPaletteColors { colors }); -} - -#[cfg(test)] -fn drain_buffer(buffer: &mut Vec, tx: &mpsc::Sender) { - for bytes in drain_complete_input_bytes(buffer) { - let Some((event, _consumed)) = extract_one_event(&bytes) else { - continue; - }; - tracing::debug!(raw_bytes = ?bytes, event = ?event, "raw input event parsed"); - let _ = tx.blocking_send(event); - } -} - -#[cfg(test)] -pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec) -> Vec> { - let mut chunks = Vec::new(); - - while let Some((_event, consumed)) = extract_one_event(buffer) { - chunks.push(buffer[..consumed].to_vec()); - buffer.drain(..consumed); - } - - chunks -} - -#[cfg(test)] -fn flush_incomplete_buffer(buffer: &mut Vec, tx: &mpsc::Sender) { - if let Some(bytes) = flush_incomplete_input_bytes(buffer) { - if bytes.as_slice() == [ESC] { - let _ = tx.blocking_send(RawInputEvent::Key( - TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) - .with_vt_bytes(bytes), - )); - return; - } - - let Some((event, _consumed)) = extract_one_event(&bytes) else { - return; - }; - let _ = tx.blocking_send(event); - } -} - -#[cfg(test)] -pub(crate) fn flush_incomplete_input_bytes(buffer: &mut Vec) -> Option> { - let mut framer = RawInputByteFramer { - buffer: std::mem::take(buffer), - ..Default::default() - }; - let mut chunks = framer.flush_timeout(); - *buffer = framer.buffer; - chunks.pop() -} - -#[cfg(unix)] -fn stdin_read_ready(_reader: &R, _timeout_ms: i32) -> Option { - #[cfg(unix)] - { - let fd = _reader.as_raw_fd(); - poll_read_ready(fd, _timeout_ms) - } -} - -#[cfg(not(unix))] -fn stdin_read_ready(_reader: &R, _timeout_ms: i32) -> Option { - None -} - -#[cfg(unix)] -fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option { - #[repr(C)] - struct PollFd { - fd: i32, - events: i16, - revents: i16, - } - - unsafe extern "C" { - fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32; - } - - const POLLIN: i16 = 0x0001; - - let mut pfd = PollFd { - fd, - events: POLLIN, - revents: 0, - }; - - let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) }; - if result < 0 { - None - } else { - Some(result > 0) - } -} - fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { if buffer.is_empty() { return None; @@ -1089,6 +812,7 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { .all(|byte| byte.is_ascii_digit() || *byte == b';') } +#[cfg(any(unix, test))] fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[M") && buffer.len() < 6 } @@ -1388,19 +1112,6 @@ mod tests { modifiers } - fn collect_events(rx: &mut mpsc::Receiver) -> Vec { - let mut events = Vec::new(); - while let Ok(event) = rx.try_recv() { - events.push(event); - } - events - } - - fn drain_chunk(buffer: &mut Vec, tx: &mpsc::Sender, chunk: &[u8]) { - buffer.extend_from_slice(chunk); - drain_buffer(buffer, tx); - } - #[test] fn parses_kitty_shift_letter_release() { let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[108:76;2:3u").unwrap() @@ -1456,7 +1167,8 @@ mod tests { #[test] fn parses_default_mouse_encoding() { - let events = parse_raw_input_bytes_sync(b"\x1b[MCN1"); + let mut framer = RawInputFramer::default(); + let events = framer.push(b"\x1b[MCN1"); let [RawInputEvent::Mouse(mouse)] = events.as_slice() else { panic!("expected one mouse event"); }; @@ -1467,13 +1179,16 @@ mod tests { #[test] fn rejected_default_mouse_frame_preserves_trailing_input() { - let events = parse_raw_input_bytes_with_ranges(b"\x1b[M\x82AAx"); + let mut framer = RawInputFramer::default(); + let events = framer.push(b"\x1b[M\x82AAx"); assert_eq!(events.len(), 2); - assert!(matches!(events[0].event, RawInputEvent::Unsupported)); - assert_eq!((events[0].start, events[0].len), (0, 6)); - assert!(matches!(events[1].event, RawInputEvent::Key(_))); - assert_eq!((events[1].start, events[1].len), (6, 1)); + assert!(matches!(events[0], RawInputEvent::Unsupported)); + assert_raw_key( + events.into_iter().nth(1).unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); } #[test] @@ -1822,15 +1537,16 @@ mod tests { #[test] fn flushes_lone_escape_after_timeout() { - let (tx, mut rx) = mpsc::channel(4); - let mut buffer = vec![ESC]; - flush_incomplete_buffer(&mut buffer, &tx); - assert!(buffer.is_empty()); - let event = rx.try_recv().unwrap(); - let RawInputEvent::Key(key) = event else { - panic!("expected key"); - }; - assert_eq!(key.code, KeyCode::Esc); + let mut framer = RawInputFramer::default(); + assert!(framer.push(&[ESC]).is_empty()); + + let events = framer.flush_timeout(); + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Esc, + KeyModifiers::empty(), + ); } #[test] @@ -1944,16 +1660,10 @@ mod tests { #[test] fn chunked_legacy_arrow_waits_for_completion() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b"); - assert_eq!(buffer, b"\x1b"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"[A"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.push(b"[A"); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -1964,16 +1674,10 @@ mod tests { #[test] fn lone_escape_is_buffered_until_timeout_flush() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b"); - assert_eq!(buffer, b"\x1b"); - assert!(collect_events(&mut rx).is_empty()); - - flush_incomplete_buffer(&mut buffer, &tx); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.flush_timeout(); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -1984,16 +1688,10 @@ mod tests { #[test] fn escape_followed_by_arrow_before_flush_does_not_emit_escape() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b"); - assert_eq!(buffer, b"\x1b"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"[B"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.push(b"[B"); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2100,37 +1798,6 @@ mod tests { assert_eq!(framer.push(b"\x1b\x1b[D"), vec![b"\x1b\x1b[D".to_vec()]); } - #[test] - fn legacy_reader_extends_incomplete_mouse_timeouts() { - let mut sgr_mouse = RawInputFramer::default(); - assert!(sgr_mouse.push(b"\x1b[<3").is_empty()); - assert_eq!( - input_flush_timeout_ms(&sgr_mouse), - MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS - ); - - let report = b"\x1b[MCN1"; - for split in 3..report.len() { - let mut default_mouse = RawInputFramer::default(); - assert!(default_mouse.push(&report[..split]).is_empty()); - assert_eq!( - input_flush_timeout_ms(&default_mouse), - MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS - ); - assert!(matches!( - default_mouse.push(&report[split..]).as_slice(), - [RawInputEvent::Mouse(_)] - )); - } - - let mut escape = RawInputFramer::default(); - assert!(escape.push(b"\x1b").is_empty()); - assert_eq!( - input_flush_timeout_ms(&escape), - RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS - ); - } - #[test] fn sgr_mouse_sequence_split_after_button_prefix_is_reassembled_before_timeout() { let mut framer = RawInputFramer::default(); @@ -2244,16 +1911,10 @@ mod tests { #[test] fn escape_followed_by_alt_char_before_flush_becomes_alt_key() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b"); - assert_eq!(buffer, b"\x1b"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"b"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.push(b"b"); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2264,16 +1925,10 @@ mod tests { #[test] fn chunked_kitty_sequence_waits_for_completion() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b[49:33;2:"); - assert_eq!(buffer, b"\x1b[49:33;2:"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"1u"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b[49:33;2:").is_empty()); + let events = framer.push(b"1u"); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2284,16 +1939,10 @@ mod tests { #[test] fn chunked_bracketed_paste_waits_for_terminator() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b[200~hello"); - assert_eq!(buffer, b"\x1b[200~hello"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"\x1b[201~"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b[200~hello").is_empty()); + let events = framer.push(b"\x1b[201~"); assert_eq!(events.len(), 1); let RawInputEvent::Paste(text) = &events[0] else { panic!("expected paste"); @@ -2303,20 +1952,11 @@ mod tests { #[test] fn incomplete_bracketed_paste_is_not_flushed_on_timeout() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, b"\x1b[200~hello\nworld"); - assert_eq!(buffer, b"\x1b[200~hello\nworld"); - assert!(collect_events(&mut rx).is_empty()); - - flush_incomplete_buffer(&mut buffer, &tx); - assert_eq!(buffer, b"\x1b[200~hello\nworld"); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, b"\x1b[201~"); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(b"\x1b[200~hello\nworld").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(b"\x1b[201~"); assert_eq!(events.len(), 1); let RawInputEvent::Paste(text) = &events[0] else { panic!("expected paste"); @@ -2326,45 +1966,48 @@ mod tests { #[test] fn complete_utf8_char_before_incomplete_char_is_drained() { - let mut buffer = "你".as_bytes().to_vec(); - buffer.push("好".as_bytes()[0]); + let mut framer = RawInputByteFramer::default(); + let mut input = "你".as_bytes().to_vec(); + input.push("好".as_bytes()[0]); - let chunks = drain_complete_input_bytes(&mut buffer); - - assert_eq!(chunks, vec!["你".as_bytes().to_vec()]); - assert_eq!(buffer, vec!["好".as_bytes()[0]]); + assert_eq!(framer.push(&input), vec!["你".as_bytes().to_vec()]); + assert_eq!(framer.push(&[]), Vec::>::new()); } #[test] fn incomplete_utf8_prefix_is_not_flushed_on_timeout() { - let mut buffer = vec!["好".as_bytes()[0]]; + let mut framer = RawInputByteFramer::default(); + let prefix = &"好".as_bytes()[..1]; - assert_eq!(flush_incomplete_input_bytes(&mut buffer), None); - assert_eq!(buffer, vec!["好".as_bytes()[0]]); + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(&"好".as_bytes()[1..]), + vec!["好".as_bytes().to_vec()] + ); } #[test] fn invalid_utf8_lead_byte_is_flushed_instead_of_buffered_forever() { - let mut buffer = vec![0xC0]; + let mut framer = RawInputByteFramer::default(); - assert_eq!(flush_incomplete_input_bytes(&mut buffer), None); - assert!(buffer.is_empty()); + assert!(framer.push(&[0xC0]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(!framer.has_pending_input()); } #[test] fn complete_utf8_char_before_incomplete_char_survives_timeout_and_next_chunk() { - let mut buffer = "你".as_bytes().to_vec(); - buffer.push("好".as_bytes()[0]); + let mut framer = RawInputByteFramer::default(); + let mut input = "你".as_bytes().to_vec(); + input.push("好".as_bytes()[0]); - let chunks = drain_complete_input_bytes(&mut buffer); - assert_eq!(chunks, vec!["你".as_bytes().to_vec()]); - assert_eq!(flush_incomplete_input_bytes(&mut buffer), None); - assert_eq!(buffer, vec!["好".as_bytes()[0]]); - - buffer.extend_from_slice(&"好".as_bytes()[1..]); - let chunks = drain_complete_input_bytes(&mut buffer); - assert_eq!(chunks, vec!["好".as_bytes().to_vec()]); - assert!(buffer.is_empty()); + assert_eq!(framer.push(&input), vec!["你".as_bytes().to_vec()]); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(&"好".as_bytes()[1..]), + vec!["好".as_bytes().to_vec()] + ); } #[test] @@ -2380,20 +2023,12 @@ mod tests { #[test] fn chunked_alt_utf8_waits_for_continuation_byte_after_escape() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); let bytes = "\x1bé".as_bytes(); - drain_chunk(&mut buffer, &tx, &bytes[..2]); - assert_eq!(buffer, bytes[..2]); - assert!(collect_events(&mut rx).is_empty()); - flush_incomplete_buffer(&mut buffer, &tx); - assert_eq!(buffer, bytes[..2]); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, &bytes[2..]); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(&bytes[..2]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(&bytes[2..]); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2404,16 +2039,10 @@ mod tests { #[test] fn chunked_utf8_waits_for_continuation_byte() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); - drain_chunk(&mut buffer, &tx, "é".as_bytes().get(..1).unwrap()); - assert_eq!(buffer, vec![0xC3]); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, "é".as_bytes().get(1..).unwrap()); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(&"é".as_bytes()[..1]).is_empty()); + let events = framer.push(&"é".as_bytes()[1..]); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2424,27 +2053,14 @@ mod tests { #[test] fn chunked_cjk_utf8_waits_for_all_continuation_bytes() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); let bytes = "好".as_bytes(); - drain_chunk(&mut buffer, &tx, &bytes[..1]); - assert_eq!(buffer, bytes[..1]); - assert!(collect_events(&mut rx).is_empty()); - flush_incomplete_buffer(&mut buffer, &tx); - assert_eq!(buffer, bytes[..1]); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, &bytes[1..2]); - assert_eq!(buffer, bytes[..2]); - assert!(collect_events(&mut rx).is_empty()); - flush_incomplete_buffer(&mut buffer, &tx); - assert_eq!(buffer, bytes[..2]); - assert!(collect_events(&mut rx).is_empty()); - - drain_chunk(&mut buffer, &tx, &bytes[2..]); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + assert!(framer.push(&bytes[..1]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(&bytes[1..2]).is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(&bytes[2..]); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2455,22 +2071,15 @@ mod tests { #[test] fn chunked_four_byte_utf8_waits_for_all_continuation_bytes() { - let (tx, mut rx) = mpsc::channel(8); - let mut buffer = Vec::new(); + let mut framer = RawInputFramer::default(); let bytes = "🙂".as_bytes(); for split in 1..bytes.len() { - drain_chunk(&mut buffer, &tx, &bytes[split - 1..split]); - assert_eq!(buffer, bytes[..split]); - assert!(collect_events(&mut rx).is_empty()); - flush_incomplete_buffer(&mut buffer, &tx); - assert_eq!(buffer, bytes[..split]); - assert!(collect_events(&mut rx).is_empty()); + assert!(framer.push(&bytes[split - 1..split]).is_empty()); + assert!(framer.flush_timeout().is_empty()); } - drain_chunk(&mut buffer, &tx, &bytes[bytes.len() - 1..]); - assert!(buffer.is_empty()); - let events = collect_events(&mut rx); + let events = framer.push(&bytes[bytes.len() - 1..]); assert_eq!(events.len(), 1); assert_raw_key( events.into_iter().next().unwrap(), @@ -2486,113 +2095,38 @@ mod tests { text.len() > 4096, "test input should exceed the client read buffer" ); - let mut buffer = text.as_bytes().to_vec(); + let mut framer = RawInputByteFramer::default(); - let chunks = drain_complete_input_bytes(&mut buffer); + let chunks = framer.push(text.as_bytes()); let rebuilt: Vec = chunks.into_iter().flatten().collect(); - assert!(buffer.is_empty()); + assert!(!framer.has_pending_input()); assert_eq!(rebuilt, text.as_bytes()); } #[test] fn long_multilingual_burst_survives_one_byte_chunks_and_timeouts() { let text = "中文かなカナ한글🙂,。".repeat(64); - let mut buffer = Vec::new(); + let mut framer = RawInputByteFramer::default(); let mut rebuilt = Vec::new(); for byte in text.as_bytes() { - buffer.push(*byte); - for chunk in drain_complete_input_bytes(&mut buffer) { - rebuilt.extend(chunk); - } - if !buffer.is_empty() { - assert_eq!(flush_incomplete_input_bytes(&mut buffer), None); + rebuilt.extend( + framer + .push(std::slice::from_ref(byte)) + .into_iter() + .flatten(), + ); + if framer.has_pending_input() { + assert!(framer.flush_timeout().is_empty()); } } - for chunk in drain_complete_input_bytes(&mut buffer) { - rebuilt.extend(chunk); - } - - assert!(buffer.is_empty()); + rebuilt.extend(framer.flush_timeout().into_iter().flatten()); + assert!(!framer.has_pending_input()); assert_eq!(rebuilt, text.as_bytes()); } - #[test] - fn parse_with_ranges_tracks_byte_offsets() { - use super::parse_raw_input_bytes_with_ranges; - - // Input: Up arrow (3 bytes) + 'a' (1 byte) + Down arrow (3 bytes) - let input = b"\x1b[Aa\x1b[B".to_vec(); - let ranges = parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(ranges.len(), 3, "should parse three events"); - - // Up arrow: \x1b[A at offset 0, length 3 - assert_eq!(ranges[0].start, 0); - assert_eq!(ranges[0].len, 3); - assert!(matches!( - &ranges[0].event, - RawInputEvent::Key(k) if k.code == KeyCode::Up - )); - - // 'a' at offset 3, length 1 - assert_eq!(ranges[1].start, 3); - assert_eq!(ranges[1].len, 1); - assert!(matches!( - &ranges[1].event, - RawInputEvent::Key(k) if k.code == KeyCode::Char('a') - )); - - // Down arrow: \x1b[B at offset 4, length 3 - assert_eq!(ranges[2].start, 4); - assert_eq!(ranges[2].len, 3); - assert!(matches!( - &ranges[2].event, - RawInputEvent::Key(k) if k.code == KeyCode::Down - )); - - // Verify the raw bytes for each event slice correctly. - assert_eq!( - &input[ranges[0].start..ranges[0].start + ranges[0].len], - b"\x1b[A" - ); - assert_eq!( - &input[ranges[1].start..ranges[1].start + ranges[1].len], - b"a" - ); - assert_eq!( - &input[ranges[2].start..ranges[2].start + ranges[2].len], - b"\x1b[B" - ); - } - - #[test] - fn parse_with_ranges_handles_single_event() { - use super::parse_raw_input_bytes_with_ranges; - - let input = b"a".to_vec(); - let ranges = parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(ranges.len(), 1); - assert_eq!(ranges[0].start, 0); - assert_eq!(ranges[0].len, 1); - } - - #[test] - fn parse_with_ranges_handles_mouse_event() { - use super::parse_raw_input_bytes_with_ranges; - - let input = b"\x1b[<0;20;10M".to_vec(); - let ranges = parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(ranges.len(), 1); - assert_eq!(ranges[0].start, 0); - assert_eq!(ranges[0].len, input.len()); - assert!(matches!(&ranges[0].event, RawInputEvent::Mouse(_))); - } - #[test] fn parses_ghostty_default_background_response() { let events = parse_raw_input_bytes_sync(b"\x1b]11;rgb:2828/2a2a/3636\x07"); @@ -2611,84 +2145,6 @@ mod tests { )); } - #[test] - fn drain_complete_input_bytes_keeps_split_default_background_response_buffered() { - let mut buffer = b"\x1b]11;rgb:2828".to_vec(); - - let chunks = drain_complete_input_bytes(&mut buffer); - - assert!(chunks.is_empty()); - assert_eq!(buffer, b"\x1b]11;rgb:2828"); - } - - #[test] - fn flush_incomplete_input_bytes_keeps_split_default_background_response_buffered() { - let mut buffer = b"\x1b]11;rgb:2828".to_vec(); - - let flushed = flush_incomplete_input_bytes(&mut buffer); - - assert!(flushed.is_none()); - assert_eq!(buffer, b"\x1b]11;rgb:2828"); - } - - #[test] - fn flush_incomplete_input_bytes_keeps_default_background_response_split_after_command() { - let mut buffer = b"\x1b]11;".to_vec(); - - let flushed = flush_incomplete_input_bytes(&mut buffer); - - assert!(flushed.is_none()); - assert_eq!(buffer, b"\x1b]11;"); - } - - #[test] - fn flush_incomplete_input_bytes_keeps_default_background_response_split_inside_st() { - let mut buffer = b"\x1b]11;rgb:2828/2a2a/3636\x1b".to_vec(); - - let flushed = flush_incomplete_input_bytes(&mut buffer); - - assert!(flushed.is_none()); - assert_eq!(buffer, b"\x1b]11;rgb:2828/2a2a/3636\x1b"); - } - - #[test] - fn drain_complete_input_bytes_keeps_bare_osc_introducer_buffered() { - let mut buffer = b"\x1b]".to_vec(); - - let chunks = drain_complete_input_bytes(&mut buffer); - - assert!(chunks.is_empty()); - assert_eq!(buffer, b"\x1b]"); - } - - #[test] - fn flush_incomplete_input_bytes_drops_bare_osc_introducer_after_timeout() { - let mut buffer = b"\x1b]".to_vec(); - - let flushed = flush_incomplete_input_bytes(&mut buffer); - - assert!(flushed.is_none()); - assert!(buffer.is_empty()); - } - - #[test] - fn flush_incomplete_input_bytes_drops_string_introducers_after_timeout() { - for bytes in [ - b"\x1b]".as_slice(), - b"\x1bP".as_slice(), - b"\x1b_".as_slice(), - b"\x1b^".as_slice(), - b"\x1bX".as_slice(), - ] { - let mut buffer = bytes.to_vec(); - - let flushed = flush_incomplete_input_bytes(&mut buffer); - - assert!(flushed.is_none(), "flushed {bytes:?}"); - assert!(buffer.is_empty(), "kept {bytes:?}"); - } - } - #[test] fn raw_input_framer_reassembles_split_default_background_response() { let mut framer = RawInputFramer::default(); @@ -2781,7 +2237,7 @@ mod tests { } #[test] - fn flush_incomplete_input_bytes_does_not_hold_non_osc_default_color_text() { + fn byte_framer_does_not_hold_non_osc_default_color_text() { let mut framer = RawInputByteFramer::default(); let chunks = framer.push(b"11;rgb:2828"); diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 33e191f4..525fc7ce 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -2123,7 +2123,6 @@ fn run_client_process( crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, local_socket, ) - .env("HERDR_RENDER_ENCODING", "terminal-ansi") .env(REATTACH_COMMAND_ENV_VAR, reattach_command) .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) .env_remove(crate::api::SOCKET_PATH_ENV_VAR) diff --git a/src/selection.rs b/src/selection.rs index 08af95f5..7290b8f0 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -32,9 +32,9 @@ enum Phase { /// A text selection within a terminal pane. #[derive(Debug, Clone)] -pub struct Selection { +pub struct Selection

{ /// Which pane the selection belongs to. - pub pane_id: PaneId, + pub pane_id: P, /// Anchor position in screen-buffer coordinates (row, col). anchor: (u32, u16), /// Current/final position in screen-buffer coordinates (row, col). @@ -43,15 +43,10 @@ pub struct Selection { phase: Phase, } -impl Selection { +impl

Selection

{ /// Start a potential selection. This records the anchor but doesn't /// make anything visible yet — the user might just be clicking. - pub fn anchor( - pane_id: PaneId, - viewport_row: u16, - col: u16, - metrics: Option, - ) -> Self { + pub fn anchor(pane_id: P, viewport_row: u16, col: u16, metrics: Option) -> Self { let anchor = (absolute_row_for_viewport_row(viewport_row, metrics), col); Self { pane_id, @@ -61,29 +56,25 @@ impl Selection { } } - /// Create an active selection from an explicit viewport-row range. - pub(crate) fn range( - pane_id: PaneId, - viewport_row: u16, - start_col: u16, - end_col: u16, - metrics: Option, - ) -> Self { - let row = absolute_row_for_viewport_row(viewport_row, metrics); + pub(crate) fn absolute_anchor(pane_id: P, anchor: (u32, u16)) -> Self { Self { pane_id, - anchor: (row, start_col), - cursor: (row, end_col), + anchor, + cursor: anchor, + phase: Phase::Anchored, + } + } + + pub(crate) fn absolute_range(pane_id: P, anchor: (u32, u16), cursor: (u32, u16)) -> Self { + Self { + pane_id, + anchor, + cursor, phase: Phase::Dragging, } } - pub(crate) fn line_range( - pane_id: PaneId, - anchor_row: u32, - cursor_row: u32, - end_col: u16, - ) -> Self { + pub(crate) fn line_range(pane_id: P, anchor_row: u32, cursor_row: u32, end_col: u16) -> Self { let (anchor_col, cursor_col) = if anchor_row <= cursor_row { (0, end_col) } else { @@ -97,13 +88,6 @@ impl Selection { } } - pub(crate) fn absolute_row_for_viewport( - viewport_row: u16, - metrics: Option, - ) -> u32 { - absolute_row_for_viewport_row(viewport_row, metrics) - } - /// Convert the anchor's absolute row and pane-relative column back to /// screen coordinates. Adds the pane origin before clamping so the /// returned (screen_row, screen_col) can be compared directly against @@ -160,15 +144,11 @@ impl Selection { } /// Whether this selection was already finalized. + #[cfg(test)] pub fn is_finalized(&self) -> bool { self.phase == Phase::Done } - /// Whether the user just clicked without dragging (not a selection). - pub fn was_just_click(&self) -> bool { - self.phase == Phase::Anchored - } - /// Whether the user just clicked without dragging (not a selection). pub fn is_just_click(&self) -> bool { self.phase == Phase::Anchored @@ -240,6 +220,10 @@ fn viewport_top_row(metrics: Option) -> u32 { .unwrap_or(0) as u32 } +pub(crate) fn absolute_row_for_viewport(viewport_row: u16, metrics: Option) -> u32 { + absolute_row_for_viewport_row(viewport_row, metrics) +} + fn absolute_row_for_viewport_row(viewport_row: u16, metrics: Option) -> u32 { viewport_top_row(metrics) + u32::from(viewport_row) } @@ -485,7 +469,7 @@ mod tests { #[test] fn click_without_drag() { let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); - assert!(sel.was_just_click()); + assert!(sel.is_just_click()); let copied = sel.finish(); assert!(!copied); } @@ -495,7 +479,7 @@ mod tests { let mut sel = Selection::anchor(PaneId::from_raw(0), 5, 10, None); sel.drag(20, 7, Rect::new(10, 5, 80, 24), None); assert!(sel.is_visible()); - assert!(!sel.was_just_click()); + assert!(!sel.is_just_click()); let copied = sel.finish(); assert!(copied); } diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index 337c388f..1813ebd9 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -3,10 +3,7 @@ //! When the user runs `herdr` with no subcommand: //! 1. Check if a server is already listening on the client socket //! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 15s) -//! 3. Attach as a thin client to the server -//! -//! The `--no-session` flag bypasses server/client entirely and runs monolithically -//! (escape hatch for users who want the traditional single-process behavior). +//! 3. Attach as a client to the server use std::io; use std::path::Path; @@ -66,7 +63,7 @@ fn is_server_listening_at(socket_path: &Path) -> bool { Ok(_) => { // Server is listening. Close the test connection immediately. // The server's handshake handler will time out on this connection - // since we don't send Hello, which is fine. + // since we don't send a handshake, which is fine. true } Err(err) @@ -117,15 +114,13 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result { Err(err) => return Err(err), }; - let hello = crate::protocol::ClientMessage::Hello { + let hello = crate::protocol::ClientMessage::TerminalHello { version: crate::protocol::PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 0, cell_height_px: 0, - requested_encoding: crate::protocol::RenderEncoding::SemanticFrame, - keybindings: crate::protocol::ClientKeybindings::Server, - launch_mode: crate::protocol::ClientLaunchMode::App, + pixel_mouse: false, }; match crate::protocol::write_message(&mut stream, &hello) { @@ -281,7 +276,7 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu /// attach as a thin client. /// /// This is the entry point called from `main.rs` when the user runs `herdr` -/// without `--no-session` and without a subcommand. +/// without a subcommand. /// /// Flow: /// 1. Check if a server is listening on the client socket diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs new file mode 100644 index 00000000..eeb49c7e --- /dev/null +++ b/src/server/client_commands.rs @@ -0,0 +1,199 @@ +use std::io; +use std::sync::mpsc; + +use tokio::sync::mpsc as tokio_mpsc; + +use crate::api::schema::{ErrorBody, ErrorResponse, Method}; + +use super::client_transport::ServerEvent; + +pub(crate) const MAX_ENDPOINT_COMMAND_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128; +pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; +const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; + +pub(crate) fn supports_client_shell_method(method: &Method) -> bool { + matches!( + method, + Method::CommandInvoke(_) + | Method::IntegrationInstall(_) + | Method::IntegrationList(_) + | Method::LayoutSetSplitRatio(_) + | Method::PaneClose(_) + | Method::PaneCopyMotion(_) + | Method::PaneCopySearch(_) + | Method::PaneEditScrollback(_) + | Method::PaneFocus(_) + | Method::PaneFocusDirection(_) + | Method::PaneInputSet(_) + | Method::PaneLinkActivate(_) + | Method::PaneRename(_) + | Method::PaneResize(_) + | Method::PaneScroll(_) + | Method::PaneSelectionRead(_) + | Method::PaneSplit(_) + | Method::PaneSwap(_) + | Method::PaneZoom(_) + | Method::ProductAnnouncementDismiss(_) + | Method::ReleaseNotesDismiss(_) + | Method::ServerReloadConfig(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::TabMove(_) + | Method::TabRename(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorkspaceMove(_) + | Method::WorkspaceMoveBlock(_) + | Method::WorkspaceRename(_) + | Method::WorktreeCreate(_) + | Method::WorktreeList(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) +} + +pub(crate) fn error_response(id: String, code: &str, message: impl Into) -> String { + serde_json::to_string(&ErrorResponse { + id, + error: ErrorBody { + code: code.into(), + message: message.into(), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"serialization_error","message":"failed to serialize endpoint response"}}"#.into() + }) +} + +pub(crate) fn error_message( + boot_id: String, + request_id: String, + code: &str, + message: impl Into, +) -> crate::protocol::ServerMessage { + let response = error_response(request_id.clone(), code, message); + crate::protocol::ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk: true, + data: response.into_bytes(), + } +} + +pub(crate) fn spawn_response_waiter( + client_id: u64, + boot_id: String, + request_id: String, + response_rx: mpsc::Receiver, + server_event_tx: tokio_mpsc::Sender, +) -> io::Result<()> { + std::thread::Builder::new() + .name("herdr-client-endpoint-response".into()) + .spawn(move || { + let response = response_rx.recv().unwrap_or_else(|_| { + error_response( + request_id.clone(), + "server_unavailable", + "endpoint command ended without a response", + ) + }); + let response = response.into_bytes(); + if response.is_empty() { + let _ = server_event_tx.blocking_send( + ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk: true, + data: Vec::new(), + }, + ); + return; + } + let chunk_count = response.len().div_ceil(ENDPOINT_RESPONSE_CHUNK_BYTES); + for (index, chunk) in response.chunks(ENDPOINT_RESPONSE_CHUNK_BYTES).enumerate() { + if server_event_tx + .blocking_send(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id: boot_id.clone(), + request_id: request_id.clone(), + final_chunk: index + 1 == chunk_count, + data: chunk.to_vec(), + }) + .is_err() + { + break; + } + } + }) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { + assert!(supports_client_shell_method(&Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ))); + assert!(supports_client_shell_method(&Method::PaneLinkActivate( + 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(), + ))); + assert!(!supports_client_shell_method(&Method::ServerStop( + crate::api::schema::EmptyParams::default(), + ))); + } + + #[test] + fn endpoint_responses_are_chunked_without_truncation() { + let (response_tx, response_rx) = mpsc::channel(); + let (event_tx, mut event_rx) = tokio_mpsc::channel(8); + spawn_response_waiter( + 7, + "boot-a".into(), + "request-a".into(), + response_rx, + event_tx, + ) + .unwrap(); + let response = "x".repeat(ENDPOINT_RESPONSE_CHUNK_BYTES + 17); + response_tx.send(response.clone()).unwrap(); + + let mut received = Vec::new(); + loop { + let ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk, + data, + } = event_rx.blocking_recv().expect("response chunk") + else { + panic!("expected response chunk"); + }; + assert_eq!(client_id, 7); + assert_eq!(boot_id, "boot-a"); + assert_eq!(request_id, "request-a"); + received.extend(data); + if final_chunk { + break; + } + } + + assert_eq!(received, response.as_bytes()); + } +} diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs new file mode 100644 index 00000000..8f0a90ee --- /dev/null +++ b/src/server/client_shell.rs @@ -0,0 +1,616 @@ +use ratatui::layout::Rect; + +use crate::app; +use crate::protocol::{self, FrameData}; + +pub(super) fn snapshot( + app: &app::App, + boot_id: &str, + revision: u64, + config_diagnostic: Option<&str>, +) -> protocol::ClientShellSnapshot { + let snapshot = app.session_snapshot(); + let workspaces = snapshot + .workspaces + .into_iter() + .zip(&app.state.workspaces) + .enumerate() + .map(|(workspace_index, (workspace, state))| { + let mut tokens = workspace.tokens.into_iter().collect::>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellWorkspace { + workspace_id: workspace.workspace_id, + active_tab_id: workspace.active_tab_id, + new_workspace_cwd: app + .resolved_new_workspace_cwd_from(workspace_index) + .display() + .to_string(), + number: workspace.number, + label: workspace.label, + custom_label: state.custom_name.is_some(), + branch: state.branch(), + git_ahead_behind: state.git_ahead_behind(), + tokens, + worktree: workspace + .worktree + .map(|worktree| protocol::ClientShellWorktree { + key: worktree.repo_key, + label: worktree.repo_name, + is_linked_worktree: worktree.is_linked_worktree, + }), + focused: workspace.focused, + agent_status: workspace.agent_status, + } + }) + .collect(); + let tabs = snapshot + .tabs + .into_iter() + .zip( + app.state + .workspaces + .iter() + .flat_map(|workspace| workspace.tabs.iter()), + ) + .map(|(tab, state)| protocol::ClientShellTab { + tab_id: tab.tab_id, + workspace_id: tab.workspace_id, + number: tab.number, + label: tab.label, + custom_label: !state.is_auto_named(), + zoomed: state.zoomed, + focused: tab.focused, + agent_status: tab.agent_status, + }) + .collect(); + let panes = snapshot + .panes + .into_iter() + .map(|pane| { + let right_click_passthrough = app + .parse_pane_id(&pane.pane_id) + .and_then(|(workspace_index, pane_id)| { + app.state + .workspaces + .get(workspace_index)? + .pane_state(pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough); + protocol::ClientShellPane { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + tab_id: pane.tab_id, + label: pane.label, + cwd: pane.cwd, + foreground_cwd: pane.foreground_cwd, + focused: pane.focused, + right_click_passthrough, + } + }) + .collect(); + let agents = snapshot + .agents + .into_iter() + .map(|agent| { + let mut state_labels = agent.state_labels.into_iter().collect::>(); + state_labels.sort_by(|left, right| left.0.cmp(&right.0)); + let mut tokens = agent.tokens.into_iter().collect::>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellAgent { + pane_id: agent.pane_id, + workspace_id: agent.workspace_id, + tab_id: agent.tab_id, + name: agent.name, + display_agent: agent.display_agent, + agent: agent.agent, + title: agent.title, + terminal_title: agent.terminal_title, + terminal_title_stripped: agent.terminal_title_stripped, + agent_status: agent.agent_status, + state_change_seq: agent.state_change_seq, + state_labels, + tokens, + focused: agent.focused, + } + }) + .collect(); + + let agent_view_label = app + .state + .agent_view_override + .as_ref() + .map(|view| view.label.clone().unwrap_or_else(|| "filtered".to_owned())); + let agent_order = crate::ui::agent_panel_entries_from(&app.state, &app.terminal_runtimes) + .into_iter() + .filter_map(|entry| app.public_pane_id(entry.ws_idx, entry.pane_id)) + .collect(); + + let zoomed = app + .state + .active + .and_then(|index| app.state.workspaces.get(index)) + .is_some_and(|workspace| workspace.zoomed); + let tab_bar_right = app + .state + .tab_bar_right + .iter() + .filter_map(|segment| match segment { + crate::app::state::TabBarStatusSegment::Zoom if zoomed => { + Some(protocol::ClientShellTabStatusSegment { + text: "ZOOM".to_owned(), + accent: true, + }) + } + crate::app::state::TabBarStatusSegment::Text(Some(text)) if !text.is_empty() => { + Some(protocol::ClientShellTabStatusSegment { + text: text.clone(), + accent: false, + }) + } + crate::app::state::TabBarStatusSegment::Zoom + | crate::app::state::TabBarStatusSegment::Text(_) => None, + }) + .collect(); + + let product_announcement = app.state.product_announcement.as_ref().map(|announcement| { + protocol::ClientShellProductAnnouncement { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + preview: announcement.preview, + } + }); + let release_notes = + app.state + .latest_release_notes + .as_ref() + .map(|notes| protocol::ClientShellReleaseNotes { + version: notes.version.clone(), + body: notes.body.clone(), + preview: notes.preview, + }); + + protocol::ClientShellSnapshot { + boot_id: boot_id.to_owned(), + revision, + config_diagnostic: config_diagnostic.map(str::to_owned), + product_announcement, + update_available: app.state.update_available.clone(), + update_install_command: app.state.update_install_command.clone(), + server_keybindings_toml: app.client_shell_keybindings_profile().map(str::to_owned), + latest_release_notes_available: app.state.latest_release_notes_available, + integration_updates_available: app.state.integration_updates_available(), + worktree_directory: app.state.worktree_directory.to_string_lossy().into_owned(), + release_notes, + focused_workspace_id: snapshot.focused_workspace_id, + focused_tab_id: snapshot.focused_tab_id, + focused_pane_id: snapshot.focused_pane_id, + tab_bar_right, + tab_bar_right_separator: app.state.tab_bar_right_separator.clone(), + agent_view_label, + agent_order, + workspaces, + tabs, + panes, + agents, + commands: app.client_shell_command_manifest(), + } +} + +pub(super) struct RenderedPaneSurface { + pub(super) frame: FrameData, + pub(super) panes: Vec, + pub(super) splits: Vec, + pub(super) popup: Option>, + pub(super) graphics: protocol::SurfaceGraphicsScene, + pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, +} + +pub(super) fn render_pane_surface( + app: &mut app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, + graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, + client_id: u64, +) -> RenderedPaneSurface { + let content_revisions_before = app + .state + .active + .and_then(|workspace_index| { + let workspace = app.state.workspaces.get(workspace_index)?; + let tab = workspace.tabs.get(workspace.active_tab)?; + Some( + tab.layout + .pane_ids() + .into_iter() + .filter_map(|pane_id| { + app.state + .runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) + .map(|runtime| (pane_id, runtime.content_seq())) + }) + .collect::>(), + ) + }) + .unwrap_or_default(); + let (buffer, cursor, hyperlinks, layout) = + crate::server::render_stream::render_tab_surface_virtual( + &app.state, + &app.terminal_runtimes, + area, + is_foreground, + cell_size, + ); + let panes = app + .state + .active + .map(|workspace_index| { + layout + .pane_infos + .iter() + .filter_map(|pane| { + app.public_pane_id(workspace_index, pane.id).map(|pane_id| { + let runtime = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane.id, + ); + let mouse_reporting = + runtime.is_some_and(|runtime| runtime.mouse_reporting_enabled()); + let sgr_pixel_mouse = + runtime.is_some_and(|runtime| runtime.sgr_pixel_mouse_enabled()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(pane.inner_rect.width) * cell_size.width_px, + u32::from(pane.inner_rect.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + let content_revision = runtime.map_or(0, |runtime| { + let after = runtime.content_seq(); + if content_revisions_before.get(&pane.id).copied() == Some(after) + && after.is_multiple_of(2) + { + after + } else { + after | 1 + } + }); + protocol::PaneSurfacePane { + pane_id, + content_revision, + rect: pane.rect.into(), + inner_rect: pane.inner_rect.into(), + scrollbar_rect: pane.scrollbar_rect.map(Into::into), + scroll: runtime.and_then(|runtime| runtime.scroll_metrics()).map( + |metrics| protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + }, + ), + focused: pane.is_focused, + mouse_reporting, + sgr_pixel_mouse, + alternate_screen_active: runtime + .is_some_and(|runtime| runtime.alternate_screen_active()), + pixel_width, + pixel_height, + } + }) + }) + .collect() + }) + .unwrap_or_default(); + let pane_frames = layout + .pane_infos + .iter() + .map(|pane| pane.rect) + .collect::>(); + let splits = layout + .split_borders + .iter() + .filter_map(|split| { + let hit_rect = split_hit_rect( + split, + app.state.pane_borders, + app.state.pane_gaps, + &pane_frames, + )?; + let direction = match split.direction { + ratatui::layout::Direction::Horizontal => { + protocol::PaneSurfaceSplitDirection::Horizontal + } + ratatui::layout::Direction::Vertical => { + protocol::PaneSurfaceSplitDirection::Vertical + } + }; + Some(protocol::PaneSurfaceSplit { + direction, + pos: split.pos, + area: split.area.into(), + hit_rect: hit_rect.into(), + path: split.path.clone(), + }) + }) + .collect(); + let popup = render_popup_surface(app, area, is_foreground, cell_size); + let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect( + app, + &layout.pane_infos, + &layout.split_borders, + popup.as_deref(), + cell_size, + graphics_delivery, + client_id, + ); + RenderedPaneSurface { + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } +} + +fn render_popup_surface( + app: &app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option> { + let popup = app.state.popup_pane.as_ref()?; + let geometry = if is_foreground { + resize_popup_runtime(app, area, cell_size)? + } else { + crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)? + }; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + let content_area = Rect::new(0, 0, geometry.inner.width, geometry.inner.height); + let (buffer, mut cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, content_area); + if !is_foreground { + cursor = None; + } + let hyperlinks = runtime.visible_hyperlinks(content_area); + let title = app + .state + .terminals + .get(&popup.terminal_id) + .and_then(|terminal| terminal.manual_label.clone()) + .unwrap_or_else(|| "popup".to_owned()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(content_area.width) * cell_size.width_px, + u32::from(content_area.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + Some(Box::new(protocol::ClientShellPopupSurface { + terminal_id: popup.terminal_id.to_string(), + title, + width: popup.width.map(client_popup_size), + height: popup.height.map(client_popup_size), + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + mouse_reporting: runtime.mouse_reporting_enabled(), + sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), + pixel_width, + pixel_height, + })) +} + +pub(super) fn resize_popup_runtime( + app: &app::App, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option { + let popup = app.state.popup_pane.as_ref()?; + let geometry = crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)?; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + if !app + .state + .direct_attach_resize_locks + .contains(&popup.terminal_id) + { + runtime.resize( + geometry.inner.height, + geometry.inner.width, + cell_size.width_px, + cell_size.height_px, + ); + } + Some(geometry) +} + +fn client_popup_size(size: crate::popup_size::PopupSize) -> protocol::ClientShellPopupSize { + match size { + crate::popup_size::PopupSize::Cells(cells) => protocol::ClientShellPopupSize::Cells(cells), + crate::popup_size::PopupSize::Percent(percent) => { + protocol::ClientShellPopupSize::Percent(percent) + } + } +} + +fn split_hit_rect( + split: &crate::layout::SplitBorder, + pane_borders: bool, + pane_gaps: bool, + pane_frames: &[Rect], +) -> Option { + let hit = match (split.direction, pane_borders, pane_gaps) { + (ratatui::layout::Direction::Horizontal, true, false) => { + Rect::new(split.pos, split.area.y, 1, split.area.height) + } + (ratatui::layout::Direction::Horizontal, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + start, + split.area.y, + split.pos.saturating_sub(start).saturating_add(1), + split.area.height, + ) + } + (ratatui::layout::Direction::Horizontal, false, true) => Rect::new( + split.pos.checked_sub(1)?, + split.area.y, + 1, + split.area.height, + ), + (ratatui::layout::Direction::Vertical, true, false) => { + Rect::new(split.area.x, split.pos, split.area.width, 1) + } + (ratatui::layout::Direction::Vertical, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + split.area.x, + start, + split.area.width, + split.pos.saturating_sub(start).saturating_add(1), + ) + } + (ratatui::layout::Direction::Vertical, false, true) => { + Rect::new(split.area.x, split.pos.checked_sub(1)?, split.area.width, 1) + } + (_, false, false) => return None, + }; + if !pane_borders + && pane_frames.iter().any(|pane| { + hit.x < pane.right() + && hit.right() > pane.x + && hit.y < pane.bottom() + && hit.bottom() > pane.y + }) + { + return None; + } + Some(hit) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_projects_cached_release_and_update_facts() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations.clear(); + app.state.update_available = Some("0.8.3".into()); + app.state.update_install_command = "herdr update".into(); + app.state.latest_release_notes_available = true; + app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Client shell".into(), + preview: true, + }); + + let snapshot = snapshot(&app, "boot", 7, None); + + assert_eq!(snapshot.update_available.as_deref(), Some("0.8.3")); + assert_eq!(snapshot.update_install_command, "herdr update"); + assert!(snapshot.latest_release_notes_available); + assert!(!snapshot.integration_updates_available); + assert_eq!( + snapshot.release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("0.8.3", "### Changed\n- Client shell", true)) + ); + } + + #[test] + fn snapshot_badges_only_outdated_integrations() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations = + vec![crate::integration::IntegrationRecommendation { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude", + command: "claude", + available: true, + path: std::path::PathBuf::from("claude-hook"), + state: crate::integration::IntegrationStatusKind::NotInstalled, + }]; + + assert!(!snapshot(&app, "boot", 1, None).integration_updates_available); + + app.state.integration_recommendations[0].state = + crate::integration::IntegrationStatusKind::Outdated; + assert!(snapshot(&app, "boot", 2, None).integration_updates_available); + } + + #[test] + fn split_hits_follow_released_border_and_gap_geometry() { + let horizontal = crate::layout::SplitBorder { + pos: 20, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![false], + }; + assert_eq!( + split_hit_rect(&horizontal, true, false, &[]), + Some(Rect::new(20, 3, 1, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, true, true, &[]), + Some(Rect::new(19, 3, 2, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[]), + Some(Rect::new(19, 3, 1, 12)) + ); + assert_eq!(split_hit_rect(&horizontal, false, false, &[]), None); + + let vertical = crate::layout::SplitBorder { + pos: 9, + direction: ratatui::layout::Direction::Vertical, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![true], + }; + assert_eq!( + split_hit_rect(&vertical, true, true, &[]), + Some(Rect::new(2, 8, 40, 2)) + ); + + let edge = crate::layout::SplitBorder { + pos: 0, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(0, 0, 1, 4), + path: Vec::new(), + }; + assert_eq!( + split_hit_rect(&edge, true, true, &[]), + Some(Rect::new(0, 0, 1, 4)) + ); + assert_eq!(split_hit_rect(&edge, false, true, &[]), None); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[Rect::new(19, 3, 1, 12)]), + None + ); + } +} diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs new file mode 100644 index 00000000..b16001cf --- /dev/null +++ b/src/server/client_shell_graphics.rs @@ -0,0 +1,25 @@ +use crate::kitty_graphics::surface::DeliveryCache; +use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; + +pub(crate) fn collect( + app: &crate::app::App, + pane_infos: &[crate::layout::PaneInfo], + split_borders: &[crate::layout::SplitBorder], + popup: Option<&ClientShellPopupSurface>, + cell_size: crate::kitty_graphics::HostCellSize, + delivered: &DeliveryCache, + client_id: u64, +) -> (SurfaceGraphicsScene, DeliveryCache) { + let popup_content_size = popup.map(|popup| (popup.frame.width, popup.frame.height)); + crate::kitty_graphics::surface::collect_scene( + app, + crate::ui::TabSurfaceView { + pane_infos, + split_borders, + }, + popup_content_size, + cell_size, + delivered, + client_id, + ) +} diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index f1237c89..673e16d3 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -18,9 +18,9 @@ use tracing::{debug, warn}; use crate::ipc::LocalStream; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings, - ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, - MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientPaneInputEvent, + RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -41,8 +41,6 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4); const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB /// Maximum structured input events accepted in one client message. const MAX_INPUT_EVENT_BATCH: usize = 4096; -/// Maximum encoded mouse report accepted with pixel geometry. -const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128; /// Channels owned by the server side of a client writer thread. #[derive(Clone, Debug)] @@ -54,10 +52,6 @@ pub(crate) struct ClientWriter { } impl ClientWriter { - pub(crate) fn replace_with_cleanup(&self, data: Vec) { - self.render.queue.replace_with_cleanup(data); - } - #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec) { self.render.try_send(data).unwrap(); @@ -254,16 +248,6 @@ impl ClientWriterQueue { Ok(()) } - fn replace_with_cleanup(&self, data: Vec) { - let mut state = self.lock_state(); - state.render = None; - state.ordered.clear(); - if state.writer_alive { - state.control.push_back(data); - self.ready.notify_one(); - } - } - fn recv(&self) -> Option { let mut state = self.lock_state(); loop { @@ -312,10 +296,20 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, - render_encoding: RenderEncoding, - keybindings: Option>, - direct_attach_requested: bool, + pixel_mouse: bool, + writer: ClientWriter, + }, + /// A client-owned shell completed its dedicated handshake. + ClientShellConnected { + client_id: u64, + surface_cols: u16, + surface_rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, direct_graphics: bool, + endpoint_keybindings: bool, + mouse_capture: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -332,17 +326,6 @@ pub(crate) enum ServerEvent { transfer_id: u64, image_id: u32, }, - /// One confirmed SGR pixel report with client read-time geometry. - ClientInputPixels { - client_id: u64, - data: Vec, - geometry: crate::input::mouse::HostGeometry, - }, - /// A client sent structured input events. - ClientInputEvents { - client_id: u64, - events: Vec, - }, /// A fully decoded interactive paste exceeded the text-input limit. ClientPasteRejected { client_id: u64, @@ -352,6 +335,7 @@ pub(crate) enum ServerEvent { /// A client sent local clipboard image bytes to paste into a remote pane. ClientClipboardImage { client_id: u64, + target: crate::protocol::ClientClipboardImageTarget, extension: String, data: Vec, }, @@ -379,6 +363,15 @@ pub(crate) enum ServerEvent { row: Option, modifiers: u8, }, + /// A direct terminal attach client delivered one structured mouse event. + ClientAttachMouse { + client_id: u64, + kind: crate::protocol::ClientMouseKind, + position: crate::protocol::ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -386,6 +379,51 @@ pub(crate) enum ServerEvent { rows: u16, cell_width_px: u32, cell_height_px: u32, + pixel_mouse: bool, + }, + /// A client-owned shell recomputed its pane viewport. + ClientShellResize { + client_id: u64, + surface_cols: u16, + surface_rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, + }, + /// A client-owned shell delivered semantic input to one stable pane target. + ClientShellPaneInput { + client_id: u64, + pane_id: String, + events: Vec, + }, + /// A client-owned shell delivered semantic input to its active popup terminal. + ClientShellPopupInput { + client_id: u64, + terminal_id: String, + events: Vec, + }, + /// A client-owned shell published one host terminal theme observation. + ClientShellHostTheme { + client_id: u64, + update: crate::protocol::ClientHostThemeUpdate, + }, + /// A client-owned shell reported whether its outer terminal has focus. + ClientShellFocus { client_id: u64, focused: bool }, + /// A client-owned shell updated its local mouse-capture preference. + ClientShellMouseCapture { client_id: u64, enabled: bool }, + /// A client-owned shell invoked one endpoint operation through this connection. + ClientShellEndpointRequest { + client_id: u64, + boot_id: String, + request: Box, + }, + /// One chunk of a deferred endpoint operation's final response is ready. + ClientShellEndpointResponseChunkReady { + client_id: u64, + boot_id: String, + request_id: String, + final_chunk: bool, + data: Vec, }, /// A client detached gracefully. ClientDetach { client_id: u64 }, @@ -404,23 +442,6 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { (clamped_cols, clamped_rows) } -fn parse_client_keybindings( - keybindings: ClientKeybindings, -) -> Result>, String> { - match keybindings { - ClientKeybindings::Server => Ok(None), - ClientKeybindings::Local { keys_toml } => { - let mut config = toml::from_str::(&keys_toml) - .map_err(|err| format!("invalid client keybindings: {err}"))?; - config.keys.command.clear(); - Ok(Some(Box::new(crate::config::LiveKeybindConfig { - prefix: config.prefix_key(), - keybinds: config.keybinds(), - }))) - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputEventLimit { WithinLimits, @@ -429,20 +450,28 @@ enum InputEventLimit { InputPayloadTooLarge { size: usize }, } -fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { +fn pane_input_event_limit(events: &[ClientPaneInputEvent]) -> InputEventLimit { let mut expanded_events = 0usize; let mut paste_bytes = 0usize; let mut input_bytes = 0usize; for event in events { expanded_events = expanded_events.saturating_add(match event { - ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), - _ => 1, + ClientPaneInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), + ClientPaneInputEvent::Mouse { + kind: + crate::protocol::ClientMouseKind::ScrollUp + | crate::protocol::ClientMouseKind::ScrollDown, + lines, + .. + } => usize::from((*lines).max(1)), + ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => 1, }); match event { - ClientInputEvent::Key { + ClientPaneInputEvent::Key { repeat_count, generated_text, - source, .. } => { if let Some(text) = generated_text { @@ -451,22 +480,25 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { .saturating_mul(usize::from((*repeat_count).max(1))), ); } - if let crate::protocol::ClientKeySource::Vt { bytes } = source { - input_bytes = input_bytes.saturating_add(bytes.len()); - } } - ClientInputEvent::TextCommit(text) => { + ClientPaneInputEvent::TextCommit(text) => { input_bytes = input_bytes.saturating_add(text.len()); } - ClientInputEvent::Paste { text } => { + ClientPaneInputEvent::Mouse { .. } => {} + ClientPaneInputEvent::Paste(text) => { paste_bytes = paste_bytes.saturating_add(text.len()); } - ClientInputEvent::Mouse { .. } - | ClientInputEvent::FocusGained - | ClientInputEvent::FocusLost => {} } } + classify_input_event_size(expanded_events, paste_bytes, input_bytes) +} + +fn classify_input_event_size( + expanded_events: usize, + paste_bytes: usize, + input_bytes: usize, +) -> InputEventLimit { if expanded_events > MAX_INPUT_EVENT_BATCH { return InputEventLimit::TooManyEvents; } @@ -514,8 +546,8 @@ fn set_client_recv_timeout( /// Handles the client handshake on a blocking thread. /// -/// Reads the `Hello` message, validates the version, sends `Welcome`, -/// and then enters a read loop forwarding messages to the server event channel. +/// Reads the `TerminalHello` or `ClientShellHello` message, validates the version, +/// sends `Welcome`, and then enters a read loop forwarding messages to the server event channel. pub(crate) fn handle_client_handshake( mut stream: LocalStream, client_id: u64, @@ -537,7 +569,7 @@ pub(crate) fn handle_client_handshake( client_id, )?; - // Read the Hello message. + // Read the handshake message. let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) { Ok(msg) => msg, Err(protocol::FramingError::UnexpectedEof) => { @@ -559,69 +591,83 @@ pub(crate) fn handle_client_handshake( client_rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, + terminal_pixel_mouse, + shell_options, ) = match hello { - ClientMessage::Hello { + ClientMessage::TerminalHello { version, cols, rows, cell_width_px, cell_height_px, - requested_encoding, - keybindings, - launch_mode, + pixel_mouse, } => { - // Version check. - match protocol::check_client_version(version) { - protocol::VersionCheck::Compatible => {} - protocol::VersionCheck::Incompatible(reason) => { - // Send rejection Welcome. - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(reason), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } + if let protocol::VersionCheck::Incompatible(reason) = + protocol::check_client_version(version) + { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::TerminalAnsi, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + let (cols, rows) = clamp_terminal_size(cols, rows); + (cols, rows, cell_width_px, cell_height_px, pixel_mouse, None) + } + ClientMessage::ClientShellHello { + version, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + } => { + if let protocol::VersionCheck::Incompatible(reason) = + protocol::check_client_version(version) + { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some(reason), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + if surface_size.cols == 0 || surface_size.rows == 0 { + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("client shell requires a non-empty pane surface".to_owned()), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); } - - let keybindings = match parse_client_keybindings(keybindings) { - Ok(keybindings) => keybindings, - Err(error) => { - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some(error), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; - - // Clamp size. - let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ( - clamped_cols, - clamped_rows, + surface_size.cols, + surface_size.rows, cell_width_px, cell_height_px, - requested_encoding, - keybindings, - launch_mode == ClientLaunchMode::TerminalAttach, - launch_mode == ClientLaunchMode::AppDirectGraphics, + false, + Some(( + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + )), ) } _ => { - // First message must be Hello. - debug!(client_id, "first message was not Hello, closing"); + debug!(client_id, "first message was not a handshake, closing"); let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), + error: Some( + "expected TerminalHello or ClientShellHello as first message".to_owned(), + ), }; let _ = protocol::write_message(&mut stream, &welcome); return Ok(()); @@ -633,6 +679,11 @@ pub(crate) fn handle_client_handshake( } // Send Welcome. + let render_encoding = if shell_options.is_some() { + RenderEncoding::SemanticFrame + } else { + RenderEncoding::TerminalAnsi + }; let welcome = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: render_encoding, @@ -667,21 +718,40 @@ pub(crate) fn handle_client_handshake( } // Notify the main loop about the new client. - let connected = ServerEvent::ClientConnected { - client_id, - cols: client_cols, - rows: client_rows, - cell_width_px, - cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, - writer, - }; + let connected = + if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) = + shell_options + { + ServerEvent::ClientShellConnected { + client_id, + surface_cols: client_cols, + surface_rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + writer, + } + } else { + ServerEvent::ClientConnected { + client_id, + cols: client_cols, + rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse: terminal_pixel_mouse, + writer, + } + }; if let Err(err) = server_event_tx.blocking_send(connected) { - if let ServerEvent::ClientConnected { writer, .. } = err.0 { - send_shutdown_to_unregistered_client(&writer); + match err.0 { + ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. } => { + send_shutdown_to_unregistered_client(&writer); + } + _ => {} } } @@ -804,86 +874,6 @@ fn client_read_loop( ServerEvent::ClientInput { client_id, data } } } - ClientMessage::InputPixels { - data, - cols, - rows, - width_px, - height_px, - } => { - let Some(geometry) = - crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px) - else { - warn!( - client_id, - cols, - rows, - width_px, - height_px, - "invalid pixel mouse geometry from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - }; - if data.len() > MAX_PIXEL_MOUSE_PAYLOAD - || crate::input::mouse::parse_report(&data).is_none() - { - warn!( - client_id, - size = data.len(), - max = MAX_PIXEL_MOUSE_PAYLOAD, - "invalid pixel mouse report from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - ServerEvent::ClientInputPixels { - client_id, - data, - geometry, - } - } - ClientMessage::InputEvents { events } => match input_event_limit(&events) { - InputEventLimit::WithinLimits => { - ServerEvent::ClientInputEvents { client_id, events } - } - InputEventLimit::TooManyEvents => { - warn!( - client_id, - count = events.len(), - "oversized input event batch from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - InputEventLimit::PasteTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured paste from client, rejecting" - ); - ServerEvent::ClientPasteRejected { - client_id, - size, - max: MAX_INPUT_PAYLOAD, - } - } - InputEventLimit::InputPayloadTooLarge { size } => { - warn!( - client_id, - size, - max = MAX_INPUT_PAYLOAD, - "oversized structured input payload from client, closing" - ); - let _ = server_event_tx - .blocking_send(ServerEvent::ClientDisconnected { client_id }); - break; - } - }, ClientMessage::ObserveTerminal { target } => { ServerEvent::ClientObserveTerminal { client_id, target } } @@ -912,7 +902,11 @@ fn client_read_loop( transfer_id, image_id, }, - ClientMessage::ClipboardImage { extension, data } => { + ClientMessage::ClipboardImage { + target, + extension, + data, + } => { if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { warn!( client_id, @@ -925,6 +919,7 @@ fn client_read_loop( } else { ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } @@ -935,6 +930,7 @@ fn client_read_loop( rows, cell_width_px, cell_height_px, + pixel_mouse, } => { let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ServerEvent::ClientResize { @@ -943,9 +939,169 @@ fn client_read_loop( rows: clamped_rows, cell_width_px, cell_height_px, + pixel_mouse, } } - ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + } => ServerEvent::ClientShellResize { + client_id, + surface_cols: surface_size.cols.max(1), + surface_rows: surface_size.rows.max(1), + cell_width_px, + cell_height_px, + pixel_mouse, + }, + ClientMessage::ClientShellHostTheme { update } => { + if matches!( + &update, + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) + if colors.len() > 256 + ) { + warn!(client_id, "invalid client shell host theme update, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellHostTheme { client_id, update } + } + ClientMessage::ClientShellFocus { focused } => { + ServerEvent::ClientShellFocus { client_id, focused } + } + ClientMessage::ClientShellMouseCapture { enabled } => { + ServerEvent::ClientShellMouseCapture { client_id, enabled } + } + ClientMessage::ClientShellPaneInput { pane_id, events } => { + match pane_input_event_limit(&events) { + InputEventLimit::WithinLimits => ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events, + }, + InputEventLimit::TooManyEvents => { + warn!( + client_id, + count = events.len(), + "oversized targeted pane input batch, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + InputEventLimit::PasteTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized targeted pane paste, rejecting" + ); + ServerEvent::ClientPasteRejected { + client_id, + size, + max: MAX_INPUT_PAYLOAD, + } + } + InputEventLimit::InputPayloadTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized targeted pane input, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + } + } + ClientMessage::ClientShellPopupInput { + terminal_id, + events, + } => match pane_input_event_limit(&events) { + InputEventLimit::WithinLimits => ServerEvent::ClientShellPopupInput { + client_id, + terminal_id, + events, + }, + InputEventLimit::TooManyEvents => { + warn!( + client_id, + count = events.len(), + "oversized popup input batch, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + InputEventLimit::PasteTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized popup paste, rejecting" + ); + ServerEvent::ClientPasteRejected { + client_id, + size, + max: MAX_INPUT_PAYLOAD, + } + } + InputEventLimit::InputPayloadTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized popup input, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + }, + ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + if boot_id.len() > crate::server::client_commands::MAX_ENDPOINT_BOOT_ID_BYTES + || request.len() > crate::server::client_commands::MAX_ENDPOINT_COMMAND_BYTES + { + warn!( + client_id, + boot_id_size = boot_id.len(), + request_size = request.len(), + "oversized client shell endpoint command, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + let Ok(request) = serde_json::from_str::(&request) + else { + warn!(client_id, "invalid client shell endpoint command, closing"); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + }; + if request.id.len() > crate::server::client_commands::MAX_ENDPOINT_REQUEST_ID_BYTES + { + warn!( + client_id, + "oversized client shell endpoint request id, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(request), + } + } + ClientMessage::Detach => { + let _ = server_event_tx.blocking_send(ServerEvent::ClientDetach { client_id }); + break; + } ClientMessage::AttachTerminal { terminal_id, takeover, @@ -970,8 +1126,22 @@ fn client_read_loop( row, modifiers, }, - ClientMessage::Hello { .. } => { - // Duplicate Hello — ignore. + ClientMessage::AttachMouse { + kind, + position, + geometry, + modifiers, + lines, + } => ServerEvent::ClientAttachMouse { + client_id, + kind, + position, + geometry, + modifiers, + lines, + }, + ClientMessage::TerminalHello { .. } | ClientMessage::ClientShellHello { .. } => { + // Duplicate handshake — ignore. continue; } }; @@ -1261,54 +1431,6 @@ mod tests { ); } - #[test] - fn parse_client_keybindings_accepts_local_profile() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -prefix = "ctrl+a" -new_tab = "prefix+t" - -[[keys.command]] -key = "prefix+g" -command = "lazygit" -"# - .to_owned(), - }) - .expect("valid client keybindings") - .expect("local profile"); - - assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a')); - assert!(keybindings - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+t")); - assert!(keybindings.keybinds.custom_commands.is_empty()); - } - - #[test] - fn parse_client_keybindings_tolerates_disabled_bindings() { - let keybindings = parse_client_keybindings(ClientKeybindings::Local { - keys_toml: r#" -[keys] -new_tab = "ctrl+notakey" -"# - .to_owned(), - }) - .expect("diagnostic-only client keybindings should be accepted") - .expect("local profile"); - - assert!(keybindings.keybinds.new_tab.bindings.is_empty()); - assert!(keybindings - .keybinds - .next_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - } - #[test] fn handshake_negotiates_terminal_ansi_encoding() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi"); @@ -1321,15 +1443,13 @@ new_tab = "ctrl+notakey" protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::TerminalHello { version: PROTOCOL_VERSION, cols: 100, rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::App, + pixel_mouse: true, }, ) .expect("write hello"); @@ -1359,19 +1479,13 @@ new_tab = "ctrl+notakey" rows, cell_width_px, cell_height_px, - render_encoding, - keybindings, - direct_attach_requested, - direct_graphics, + pixel_mouse, writer, } => { assert_eq!(client_id, 42); assert_eq!((cols, rows), (100, 30)); assert_eq!((cell_width_px, cell_height_px), (8, 16)); - assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); - assert!(keybindings.is_none()); - assert!(!direct_attach_requested); - assert!(!direct_graphics); + assert!(pixel_mouse); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), @@ -1386,59 +1500,66 @@ new_tab = "ctrl+notakey" } #[test] - fn handshake_marks_terminal_attach_launch_mode() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-handshake-terminal-attach"); + fn dedicated_client_shell_handshake_uses_surface_viewport() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-shell-handshake"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let handshake_quit = should_quit.clone(); let handle = std::thread::spawn(move || { - handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit) + handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) }); protocol::write_message( &mut client_stream, - &ClientMessage::Hello { + &ClientMessage::ClientShellHello { version: PROTOCOL_VERSION, - cols: 100, - rows: 30, cell_width_px: 8, cell_height_px: 16, - requested_encoding: RenderEncoding::TerminalAnsi, - keybindings: ClientKeybindings::Server, - launch_mode: ClientLaunchMode::TerminalAttach, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 29 }, + pixel_mouse: true, + direct_graphics: true, + endpoint_keybindings: true, + mouse_capture: true, }, ) - .expect("write hello"); + .expect("write shell hello"); let welcome: ServerMessage = protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); - match welcome { + assert!(matches!( + welcome, ServerMessage::Welcome { - version, - encoding, - error, - } => { - assert_eq!(version, PROTOCOL_VERSION); - assert_eq!(encoding, RenderEncoding::TerminalAnsi); - assert_eq!(error, None); + encoding: RenderEncoding::SemanticFrame, + error: None, + .. } - other => panic!("expected Welcome, got {other:?}"), - } - + )); match server_event_rx .blocking_recv() - .expect("client connected event") + .expect("client shell connected event") { - ServerEvent::ClientConnected { - direct_attach_requested, + ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, writer, - .. } => { - assert!(direct_attach_requested); + assert_eq!(client_id, 43); + assert_eq!((surface_cols, surface_rows), (80, 29)); + assert_eq!((cell_width_px, cell_height_px), (8, 16)); + assert!(pixel_mouse); + assert!(direct_graphics); + assert!(endpoint_keybindings); + assert!(mouse_capture); drop(writer); } - other => panic!("expected ClientConnected, got {other:?}"), + other => panic!("expected ClientShellConnected, got {other:?}"), } drop(client_stream); @@ -1449,6 +1570,85 @@ new_tab = "ctrl+notakey" .expect("handshake thread result"); } + #[test] + fn dedicated_client_shell_handshake_rejects_empty_surface() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-shell-empty-surface"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let handshake_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 0, rows: 29 }, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + }, + ) + .expect("write empty shell hello"); + + let welcome: ServerMessage = + protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome"); + assert!(matches!( + welcome, + ServerMessage::Welcome { + encoding: RenderEncoding::SemanticFrame, + error: Some(error), + .. + } if error.contains("non-empty pane surface") + )); + handle + .join() + .expect("handshake thread join") + .expect("handshake thread result"); + assert!(server_event_rx.try_recv().is_err()); + } + + #[test] + fn client_read_loop_stops_after_detach() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-detach"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + let mut messages = Vec::new(); + protocol::write_message(&mut messages, &ClientMessage::Detach).unwrap(); + protocol::write_message( + &mut messages, + &ClientMessage::ClipboardImage { + target: crate::protocol::ClientClipboardImageTarget::DirectTerminal, + extension: "png".into(), + data: vec![1, 2, 3], + }, + ) + .unwrap(); + client_stream + .write_all(&messages) + .expect("write detach and trailing message"); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + assert!(server_event_rx.try_recv().is_err()); + } + #[test] fn client_read_loop_rejects_oversized_bracketed_paste_without_disconnect() { let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized"); @@ -1555,76 +1755,6 @@ new_tab = "ctrl+notakey" .expect("read thread result"); } - #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-geometry"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: b"\x1b[<35;1;1M".to_vec(), - cols: 0, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel geometry"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_disconnects_invalid_pixel_mouse_report() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-invalid-pixel-report"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputPixels { - data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1], - cols: 80, - rows: 24, - width_px: 800, - height_px: 480, - }, - ) - .expect("write invalid pixel report"); - - assert!(matches!( - recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"), - ServerEvent::ClientDisconnected { client_id: 7 } - )); - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - #[test] fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() { let (mut client_stream, server_stream, _path) = @@ -1655,61 +1785,8 @@ new_tab = "ctrl+notakey" } #[test] - fn client_read_loop_forwards_input_events() { - let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - let events = vec![ - ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Enter, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - ClientInputEvent::FocusGained, - ]; - - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: events.clone(), - }, - ) - .expect("write input events"); - - match server_event_rx - .blocking_recv() - .expect("client input events event") - { - ServerEvent::ClientInputEvents { - client_id, - events: actual, - } => { - assert_eq!(client_id, 7); - assert_eq!(actual, events); - } - other => panic!("expected ClientInputEvents, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_rejects_oversized_input_event_batch() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-events"); + fn client_read_loop_uses_authoritative_shell_resize_surface() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-resize"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let read_quit = should_quit.clone(); @@ -1719,154 +1796,126 @@ new_tab = "ctrl+notakey" protocol::write_message( &mut client_stream, - &ClientMessage::InputEvents { - events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1], + &ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 60, rows: 15 }, + pixel_mouse: true, }, ) - .expect("write oversized input events"); - - match server_event_rx - .blocking_recv() - .expect("client disconnected event") - { - ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7), - other => panic!("expected ClientDisconnected, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn client_read_loop_rejects_oversized_input_event_paste() { - let (mut client_stream, server_stream, _path) = - local_stream_pair("client-read-oversized-paste"); - let (server_event_tx, mut server_event_rx) = mpsc::channel(4); - let should_quit = Arc::new(AtomicBool::new(false)); - let read_quit = should_quit.clone(); - let handle = std::thread::spawn(move || { - client_read_loop(server_stream, 7, &server_event_tx, &read_quit) - }); - - let maximum = vec![ - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2)), - }, - ]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: maximum.clone(), - }, - ) - .expect("write maximum-size structured paste"); - - match recv_server_event(&mut server_event_rx, "maximum-size structured paste") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, maximum); - } - other => panic!("expected maximum-size ClientInputEvents, got {other:?}"), - } - - let oversized = vec![ - ClientInputEvent::FocusGained, - ClientInputEvent::Paste { - text: "x".repeat(MAX_INPUT_PAYLOAD / 2), - }, - ClientInputEvent::Paste { - text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2) + 1), - }, - ClientInputEvent::FocusLost, - ClientInputEvent::Paste { - text: "tail".to_owned(), - }, - ]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { events: oversized }, - ) - .expect("write oversized structured paste"); - - match recv_server_event(&mut server_event_rx, "oversized structured paste rejection") { - ServerEvent::ClientPasteRejected { - client_id, - size, - max, - } => { - assert_eq!(client_id, 7); - assert_eq!(size, MAX_INPUT_PAYLOAD + 5); - assert_eq!(max, MAX_INPUT_PAYLOAD); - } - other => panic!("expected ClientPasteRejected, got {other:?}"), - } - - let valid = vec![ClientInputEvent::FocusGained]; - protocol::write_message( - &mut client_stream, - &ClientMessage::InputEvents { - events: valid.clone(), - }, - ) - .expect("write valid structured input after rejection"); - - match recv_server_event(&mut server_event_rx, "structured input after rejection") { - ServerEvent::ClientInputEvents { client_id, events } => { - assert_eq!(client_id, 7); - assert_eq!(events, valid); - } - other => panic!("expected ClientInputEvents after rejection, got {other:?}"), - } - - drop(client_stream); - should_quit.store(true, Ordering::Release); - handle - .join() - .expect("read thread join") - .expect("read thread result"); - } - - #[test] - fn structured_input_limits_charge_grouped_repeats_and_text_payloads() { - let grouped = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - assert_eq!( - input_event_limit(&[grouped]), - InputEventLimit::TooManyEvents - ); - - let repeated_text = ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: MAX_INPUT_EVENT_BATCH as u16, - generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)), - source: crate::protocol::ClientKeySource::Synthesized, - }; + .expect("write shell resize"); assert!(matches!( - input_event_limit(&[repeated_text]), - InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD + recv_server_event(&mut server_event_rx, "shell resize"), + ServerEvent::ClientShellResize { + client_id: 7, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: true, + } )); - let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1)); - assert_eq!( - input_event_limit(&[text]), - InputEventLimit::InputPayloadTooLarge { - size: MAX_INPUT_PAYLOAD + 1 + protocol::write_message(&mut client_stream, &ClientMessage::Detach).expect("write detach"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "detach event"), + ServerEvent::ClientDetach { client_id: 7 } + )); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn client_read_loop_keeps_single_host_theme_updates_ordered_and_palette_bounded() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-host-theme"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + let colors = (0..=u8::MAX) + .map(|index| { + ( + index, + crate::protocol::ClientHostColor { + r: index, + g: 0, + b: 0, + }, + ) + }) + .collect(); + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + }, + ) + .expect("write bounded palette update"); + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark, + ), + }, + ) + .expect("write ordered appearance update"); + + assert!(matches!( + recv_server_event(&mut server_event_rx, "bounded palette update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + } if colors.len() == 256 + )); + assert!(matches!( + recv_server_event(&mut server_event_rx, "ordered appearance update"), + ServerEvent::ClientShellHostTheme { + client_id: 7, + update: crate::protocol::ClientHostThemeUpdate::Appearance( + crate::protocol::ClientHostAppearance::Dark + ), } + )); + + let colors = vec![(0, crate::protocol::ClientHostColor { r: 0, g: 0, b: 0 },); 257]; + protocol::write_message( + &mut client_stream, + &ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors), + }, + ) + .expect("write oversized palette update"); + assert!(matches!( + recv_server_event(&mut server_event_rx, "oversized palette disconnect"), + ServerEvent::ClientDisconnected { client_id: 7 } + )); + + drop(client_stream); + should_quit.store(true, Ordering::Release); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn pane_input_limits_charge_scroll_repeats() { + let oversized_scroll = ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::ScrollUp, + position: crate::protocol::ClientMousePosition::Cell { column: 0, row: 0 }, + geometry: None, + modifiers: 0, + lines: (MAX_INPUT_EVENT_BATCH + 1) as u16, + }; + assert_eq!( + pane_input_event_limit(&[oversized_scroll]), + InputEventLimit::TooManyEvents ); } diff --git a/src/server/clients.rs b/src/server/clients.rs index 0d3d9ae0..d380e2c3 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -1,13 +1,17 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::protocol::RenderEncoding; +use crate::protocol::{ + ClientKeyCode, ClientKeyKind, ClientMouseButton, ClientMouseKind, ClientPaneInputEvent, + RenderEncoding, +}; use crate::server::client_transport::ClientWriter; use crate::server::render_stream::ClientRenderState; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ClientConnectionMode { - App, + ClientShell, + TerminalPending, TerminalAttach { terminal_id: String }, TerminalObserve { terminal_id: String }, } @@ -20,6 +24,35 @@ pub(crate) type RenderTarget = ( ClientConnectionMode, ); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientShellInputTarget { + Pane(String), + Popup(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum ClientShellPressId { + PhysicalKey(u32), + SemanticKey(ClientKeyCode), + Mouse(ClientMouseButton), +} + +fn client_shell_key_press_id( + code: &ClientKeyCode, + physical_key_id: Option, +) -> ClientShellPressId { + physical_key_id.map_or_else( + || ClientShellPressId::SemanticKey(code.clone()), + ClientShellPressId::PhysicalKey, + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientShellHeldInput { + pub(crate) target: ClientShellInputTarget, + pub(crate) release: ClientPaneInputEvent, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum DeferredRender { #[default] @@ -29,48 +62,54 @@ pub(crate) enum DeferredRender { /// A connected client tracked by the server. pub(crate) struct ClientConnection { - /// Whether this connection is the full app client or a direct terminal attach. + /// Whether this connection owns the Herdr shell or one direct terminal stream. pub(crate) mode: ClientConnectionMode, - /// True after the handshake for clients that will switch into direct terminal attach mode. - pub(crate) pending_terminal_attach: bool, - /// Client-local app keybindings. None means use the server's keybindings. - pub(crate) keybindings: Option>, /// The client's terminal size after clamping. pub(crate) terminal_size: (u16, u16), /// Pixel size of one client terminal cell. pub(crate) cell_size: crate::kitty_graphics::HostCellSize, - /// Last known host terminal default colors for this client. - pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, - /// Last known host terminal appearance for this client. - pub(crate) host_terminal_appearance: Option, - /// True when appearance came from an explicit host color-scheme report. - pub(crate) host_terminal_appearance_explicit: bool, - /// Last reported focus state for this client's outer terminal. - pub(crate) outer_terminal_focus: Option, - /// Stateful parser for app-client input split across transport reads. - pub(crate) raw_input: crate::raw_input::RawInputFramer, /// Monotonic activity stamp used to choose the fallback foreground client. pub(crate) last_activity: u64, /// Render baseline for the negotiated client encoding. pub(crate) render_state: ClientRenderState, - /// Client-local host Kitty graphics cache. - pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache, + /// Image assets already included in the selected ClientShell scene. + pub(crate) shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, /// Passive eligibility for audited local Kitty regular-file graphics. pub(crate) direct_graphics: bool, /// Whether this frontend preserves exact SGR pixel reports. pub(crate) pixel_mouse: bool, - /// Whether the next graphics frame must clear and rebuild host-side Kitty state. - pub(crate) graphics_surface_reset_pending: bool, + /// Last host terminal default colors reported by this client. + pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last host light/dark appearance reported by this client. + pub(crate) host_terminal_appearance: Option, + /// Whether appearance came from an explicit host color-scheme report. + pub(crate) host_terminal_appearance_explicit: bool, + /// Last reported focus state for this client's outer terminal. + pub(crate) outer_terminal_focus: Option, + /// Last focused-pane report-all demand sent to a client-owned shell. + pub(crate) host_keyboard_report_all_active: Option, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this shell wants host mouse capture without pane demand. + pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. pub(crate) host_mouse_capture_active: Option, /// Last SGR pixel provenance mode sent to this client. pub(crate) host_sgr_pixels_active: Option, - /// Last Kitty report-all mode sent to this client's host terminal. - pub(crate) host_keyboard_report_all_active: Option, + /// Last keyboard protocol state sent to a directly attached terminal client. + pub(crate) host_keyboard_protocol_active: Option<(u16, u8)>, + /// Presses forwarded by this shell that need release on abrupt teardown. + shell_held_inputs: HashMap, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec, + /// Last coherent shell replacement sent to this client. + pub(crate) shell_snapshot: 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. + pub(crate) shell_endpoint_command_in_flight: bool, + /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap. + pub(crate) shell_uses_endpoint_keybindings: bool, /// Channels for sending framed ServerMessage data to the client writer thread. pub(crate) writer: Option, } @@ -80,62 +119,53 @@ impl ClientConnection { pub(crate) fn new( terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, writer: Option, ) -> Self { Self::new_with_mode( - ClientConnectionMode::App, - None, + ClientConnectionMode::ClientShell, terminal_size, cell_size, - host_terminal_theme, - outer_terminal_focus, last_activity, render_encoding, - false, writer, ) } pub(crate) fn new_with_mode( mode: ClientConnectionMode, - keybindings: Option>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - outer_terminal_focus: Option, last_activity: u64, render_encoding: RenderEncoding, - pending_terminal_attach: bool, writer: Option, ) -> Self { Self { mode, - pending_terminal_attach, - keybindings, terminal_size, cell_size, - host_terminal_appearance: host_terminal_theme - .background - .map(crate::terminal_theme::RgbColor::inferred_appearance), - host_terminal_appearance_explicit: false, - host_terminal_theme, - outer_terminal_focus, - raw_input: crate::raw_input::RawInputFramer::default(), last_activity, render_state: ClientRenderState::new(render_encoding), - graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), + shell_graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), direct_graphics: false, pixel_mouse: false, - graphics_surface_reset_pending: false, + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + host_terminal_appearance: None, + host_terminal_appearance_explicit: false, + outer_terminal_focus: None, + host_keyboard_report_all_active: None, render_pending: false, + shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, - host_keyboard_report_all_active: None, + host_keyboard_protocol_active: None, + shell_held_inputs: HashMap::new(), staged_clipboard_files: Vec::new(), + shell_snapshot: None, + shell_projection_revision: 0, + shell_endpoint_command_in_flight: false, + shell_uses_endpoint_keybindings: false, writer, } } @@ -144,62 +174,151 @@ impl ClientConnection { self.render_state.request_repaint(); } - pub(crate) fn deferred_render(&self) -> DeferredRender { - if self.render_pending { - DeferredRender::Full - } else { - DeferredRender::None + pub(crate) fn track_shell_input( + &mut self, + target: ClientShellInputTarget, + events: &[ClientPaneInputEvent], + ) { + for event in events { + match event { + ClientPaneInputEvent::Key { + code, + modifiers, + kind: ClientKeyKind::Press, + shifted_codepoint, + tracks_release: true, + physical_key_id, + .. + } => { + self.shell_held_inputs.insert( + client_shell_key_press_id(code, *physical_key_id), + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Key { + code: code.clone(), + modifiers: *modifiers, + kind: ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: *shifted_codepoint, + generated_text: None, + tracks_release: true, + physical_key_id: *physical_key_id, + }, + }, + ); + } + ClientPaneInputEvent::Key { + code, + kind: ClientKeyKind::Release, + physical_key_id, + .. + } => { + self.shell_held_inputs + .remove(&client_shell_key_press_id(code, *physical_key_id)); + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(button), + position, + geometry, + modifiers, + .. + } + | ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Drag(button), + position, + geometry, + modifiers, + .. + } => { + let id = ClientShellPressId::Mouse(*button); + if matches!( + event, + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Down(_), + .. + } + ) || self.shell_held_inputs.contains_key(&id) + { + self.shell_held_inputs.insert( + id, + ClientShellHeldInput { + target: target.clone(), + release: ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(*button), + position: *position, + geometry: *geometry, + modifiers: *modifiers, + lines: 1, + }, + }, + ); + } + } + ClientPaneInputEvent::Mouse { + kind: ClientMouseKind::Up(button), + .. + } => { + self.shell_held_inputs + .remove(&ClientShellPressId::Mouse(*button)); + } + ClientPaneInputEvent::Key { + kind: ClientKeyKind::Press | ClientKeyKind::Repeat, + .. + } + | ClientPaneInputEvent::TextCommit(_) + | ClientPaneInputEvent::Mouse { .. } + | ClientPaneInputEvent::Paste(_) => {} + } } } - pub(crate) fn clear_deferred_render(&mut self) { - self.render_pending = false; + pub(crate) fn drain_shell_held_inputs(&mut self) -> Vec { + self.shell_held_inputs + .drain() + .map(|(_, held)| held) + .collect() } - pub(crate) fn defer_full_render(&mut self) { - self.render_pending = true; - } - - pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { - let deferred = self.deferred_render(); - self.clear_deferred_render(); - deferred - } - - pub(crate) fn is_full_app_client(&self) -> bool { - matches!(self.mode, ClientConnectionMode::App) && !self.pending_terminal_attach - } - - pub(crate) fn request_semantic_redraw_after_input(&mut self) { - self.render_state.reset_semantic_input_baseline(); - } - - pub(crate) fn update_host_theme_from_events( + pub(crate) fn update_host_theme( &mut self, - events: &[crate::raw_input::RawInputEvent], + update: &crate::protocol::ClientHostThemeUpdate, ) -> bool { let mut next_theme = self.host_terminal_theme; let mut changed = false; - for event in events { - match event { - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - next_theme = next_theme.with_color(*kind, *color); - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.host_terminal_appearance_explicit - { - changed |= - self.set_host_appearance(Some(color.inferred_appearance()), false); + + match update { + crate::protocol::ClientHostThemeUpdate::DefaultColor { kind, color } => { + let kind = match kind { + crate::protocol::ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); + crate::protocol::ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background } + }; + let color = (*color).into(); + next_theme = next_theme.with_color(kind, color); + if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) + && !self.host_terminal_appearance_explicit + { + changed |= self.set_host_appearance(Some(color.inferred_appearance()), false); } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - changed |= self.set_host_appearance(Some(*appearance), true); + } + crate::protocol::ClientHostThemeUpdate::PaletteColors(colors) => { + for &(index, color) in colors { + next_theme = next_theme.with_palette_color(index, color.into()); } - _ => {} + } + crate::protocol::ClientHostThemeUpdate::Appearance(appearance) => { + let appearance = match appearance { + crate::protocol::ClientHostAppearance::Dark => { + crate::terminal_theme::HostAppearance::Dark + } + crate::protocol::ClientHostAppearance::Light => { + crate::terminal_theme::HostAppearance::Light + } + }; + changed |= self.set_host_appearance(Some(appearance), true); } } @@ -228,41 +347,37 @@ impl ClientConnection { true } - pub(crate) fn update_outer_focus_from_events( - &mut self, - events: &[crate::raw_input::RawInputEvent], - ) -> Option { - let next_focus = events - .iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), - crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), - _ => None, - }) - .next_back()?; + pub(crate) fn deferred_render(&self) -> DeferredRender { + if self.render_pending { + DeferredRender::Full + } else { + DeferredRender::None + } + } - self.outer_terminal_focus = Some(next_focus); - Some(next_focus) + pub(crate) fn clear_deferred_render(&mut self) { + self.render_pending = false; + } + + pub(crate) fn defer_full_render(&mut self) { + self.render_pending = true; + } + + pub(crate) fn take_deferred_render(&mut self) -> DeferredRender { + let deferred = self.deferred_render(); + self.clear_deferred_render(); + deferred + } + + pub(crate) fn is_shell_client(&self) -> bool { + matches!(self.mode, ClientConnectionMode::ClientShell) } } -pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { - events.iter().any(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) - | crate::raw_input::RawInputEvent::OuterFocusGained - ) - }) -} - -pub(crate) fn latest_app_client(clients: &HashMap) -> Option { +pub(crate) fn latest_shell_client(clients: &HashMap) -> Option { clients .iter() - .filter(|(_, client)| client.is_full_app_client()) + .filter(|(_, client)| client.is_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } @@ -293,7 +408,7 @@ pub(crate) fn render_targets( .iter() .filter(|(_, client)| { client.writer.is_some() - && (client.is_full_app_client() + && (client.is_shell_client() || matches!( client.mode, ClientConnectionMode::TerminalAttach { .. } @@ -314,3 +429,73 @@ pub(crate) fn render_targets( targets.sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); targets } + +#[cfg(test)] +mod tests { + use super::*; + + fn shell_client() -> ClientConnection { + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + crate::protocol::RenderEncoding::SemanticFrame, + None, + ) + } + + #[test] + fn semantic_text_press_does_not_create_a_server_release_lease() { + let mut client = shell_client(); + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: Some("x".into()), + tracks_release: false, + physical_key_id: None, + }], + ); + + assert!(client.drain_shell_held_inputs().is_empty()); + } + + #[test] + fn physical_keys_with_the_same_semantic_code_keep_distinct_release_leases() { + let mut client = shell_client(); + let key = |kind, physical_key_id| ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(physical_key_id), + }; + client.track_shell_input( + ClientShellInputTarget::Pane("w1:p1".into()), + &[ + key(ClientKeyKind::Press, 13), + key(ClientKeyKind::Press, 108), + key(ClientKeyKind::Release, 13), + ], + ); + + let held = client.drain_shell_held_inputs(); + assert_eq!(held.len(), 1); + assert!(matches!( + &held[0].release, + ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + kind: ClientKeyKind::Release, + physical_key_id: Some(108), + .. + } + )); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 7b6461b7..6bae0a98 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -19,9 +19,8 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crossterm::event::{KeyModifiers, MouseEventKind}; use interprocess::local_socket::traits::Listener as _; #[cfg(windows)] use interprocess::local_socket::traits::Stream as _; @@ -29,7 +28,9 @@ use interprocess::local_socket::traits::Stream as _; use interprocess::local_socket::ListenerNonblockingMode; use ratatui::layout::Rect; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +#[cfg(windows)] +use tracing::error; +use tracing::{debug, info, warn}; use base64::Engine; use bytes::Bytes; @@ -49,24 +50,41 @@ use crate::protocol::{ use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; +use crate::server::client_shell::{ + render_pane_surface as render_client_shell_pane_surface, + resize_popup_runtime as resize_client_shell_popup_runtime, snapshot as client_shell_snapshot, +}; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ - events_include_interaction, latest_app_client, render_targets, terminal_stream_client_ids, - ClientConnection, ClientConnectionMode, DeferredRender, + latest_shell_client, render_targets, terminal_stream_client_ids, ClientConnection, + ClientConnectionMode, ClientShellInputTarget, DeferredRender, }; use crate::server::keybindings::{app_keybindings, apply_keybindings}; use crate::server::notifications::{ should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind, }; +use crate::server::pane_input::{ + apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input, + apply_terminal_attach_scroll, terminal_attach_mouse_position, +}; use crate::server::socket_paths::{ client_socket_path, prepare_socket_path, restrict_socket_permissions, }; use crate::server::terminal_attach::paste_payload_for_runtime; +mod bootstrap; +mod lifecycle; +mod notifications; mod pane_graphics; +mod render; +mod retained_surface; + +pub use bootstrap::run_server; +use lifecycle::wait_for_live_handoff_response_write; +#[cfg(unix)] +use lifecycle::wait_for_old_public_sockets_to_close; use crate::protocol::MAX_GRAPHICS_FRAME_SIZE; -use pane_graphics::RetainedGraphicsOutcome; #[cfg(test)] use crate::protocol::RenderEncoding; @@ -75,26 +93,6 @@ use crate::server::client_transport::ClientWriter; #[cfg(test)] use std::fs; -const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); - -fn wait_for_live_handoff_response_write( - response_write_complete: Option>, -) { - let Some(response_write_complete) = response_write_complete else { - return; - }; - - match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { - Ok(()) => {} - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - warn!("timed out waiting for live handoff response write; old server exiting"); - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - warn!("live handoff response writer disconnected; old server exiting"); - } - } -} - fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { match sound { crate::sound::Sound::Done => "agent done", @@ -102,14 +100,16 @@ fn sound_notify_message(sound: crate::sound::Sound) -> &'static str { } } -fn notification_show_response_shown(response: &str) -> bool { - let Ok(response) = serde_json::from_str::(response) else { - return false; - }; - matches!( - response.result, - api::schema::ResponseResult::NotificationShow { shown: true, .. } - ) +fn notification_show_result( + id: String, + shown: bool, + reason: api::schema::NotificationShowReason, +) -> String { + serde_json::to_string(&api::schema::SuccessResponse { + id, + result: api::schema::ResponseResult::NotificationShow { shown, reason }, + }) + .unwrap_or_else(|_| "{}".to_string()) } fn non_empty_body(value: &str) -> Option { @@ -143,42 +143,6 @@ impl RenderImpact { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PtyRenderState { - Clean, - Hidden, - Visible, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RetainedRenderInput { - needs_full_render: bool, - needs_graphics_render: bool, - pty: PtyRenderState, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RetainedRenderPlan { - Full, - Graphics, - Pty, - HiddenPty, -} - -fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { - if input.needs_full_render { - RetainedRenderPlan::Full - } else if input.needs_graphics_render && input.pty != PtyRenderState::Visible { - RetainedRenderPlan::Graphics - } else { - match input.pty { - PtyRenderState::Visible => RetainedRenderPlan::Pty, - PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty, - PtyRenderState::Clean => RetainedRenderPlan::Full, - } - } -} - fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -190,64 +154,6 @@ fn record_render_impact(source: &'static str, impact: RenderImpact) { crate::render_prof::event(event); } -fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool { - rect.x.saturating_add(rect.width) <= frame.width - && rect.y.saturating_add(rect.height) <= frame.height -} - -fn apply_terminal_dirty_patch( - frame: &mut FrameData, - area: Rect, - patch: crate::pane::TerminalDirtyPatch, -) -> bool { - if !rect_fits_frame(area, frame) { - return false; - } - let width = usize::from(frame.width); - for (local_y, row_cells) in patch.rows { - if local_y >= area.height || row_cells.len() != usize::from(area.width) { - return false; - } - let frame_y = area.y + local_y; - let start = usize::from(frame_y) * width + usize::from(area.x); - let end = start + usize::from(area.width); - if end > frame.cells.len() { - return false; - } - frame.cells[start..end].clone_from_slice(&row_cells); - } - true -} - -fn dirty_patch_intersects_hyperlinks( - frame: &FrameData, - area: Rect, - patch: &crate::pane::TerminalDirtyPatch, -) -> bool { - if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { - return false; - } - let width = usize::from(frame.width); - for (local_y, _) in &patch.rows { - if *local_y >= area.height { - return true; - } - let frame_y = area.y + *local_y; - let start = usize::from(frame_y) * width + usize::from(area.x); - let end = start + usize::from(area.width); - if end > frame.cells.len() { - return true; - } - if frame.cells[start..end] - .iter() - .any(|cell| cell.hyperlink.is_some()) - { - return true; - } - } - false -} - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -300,6 +206,8 @@ pub struct HeadlessServer { next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option, + /// Process-local identity used to reject shell replacements from an earlier server boot. + client_shell_boot_id: String, /// Outer window title last pushed, paired with the client that received it. /// Keying on the client means a newly attached terminal is written to even /// when the title itself has not changed, without every code path that @@ -342,86 +250,6 @@ pub struct HeadlessServer { server_event_tx: mpsc::Sender, } -fn apply_terminal_attach_scroll( - runtime: &crate::terminal::TerminalRuntime, - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option, - row: Option, - modifiers: u8, -) -> Result<(), String> { - let wheel_kind = match direction { - AttachScrollDirection::Up => MouseEventKind::ScrollUp, - AttachScrollDirection::Down => MouseEventKind::ScrollDown, - }; - if let AttachScrollSource::PageKey { input } = source { - let host_scroll = runtime - .plain_page_keys_use_host_scrollback() - .unwrap_or(false); - if host_scroll { - match direction { - AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), - AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), - } - return Ok(()); - } - return apply_terminal_attach_input(runtime, input); - } - - match runtime.wheel_routing() { - Some(crate::pane::WheelRouting::MouseReport) => { - runtime.scroll_reset(); - let position = crate::input::mouse::Position::Cell { - column: column.unwrap_or(0), - row: row.unwrap_or(0), - }; - let Some(bytes) = runtime.encode_mouse_wheel( - wheel_kind, - position, - KeyModifiers::from_bits_truncate(modifiers), - ) else { - return Err(format!( - "failed to encode terminal attach mouse wheel event: {wheel_kind:?}" - )); - }; - runtime - .try_send_bytes(Bytes::from(bytes)) - .map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?; - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - runtime.scroll_reset(); - let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else { - return Ok(()); - }; - runtime - .try_send_bytes(Bytes::from(bytes)) - .map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?; - } - Some(crate::pane::WheelRouting::HostScroll) | None => match direction { - AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), - AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), - }, - } - Ok(()) -} - -fn apply_terminal_attach_input( - runtime: &crate::terminal::TerminalRuntime, - data: Vec, -) -> Result<(), String> { - runtime.scroll_reset(); - if let Some(text) = crate::raw_input::complete_text_bracketed_paste(&data) { - runtime - .try_send_paste(text.to_owned()) - .map_err(|err| format!("terminal attach paste failed: {err}")) - } else { - runtime - .try_send_bytes(Bytes::from(data)) - .map_err(|err| format!("terminal attach input failed: {err}")) - } -} - #[cfg(windows)] fn spawn_windows_client_accept_thread( listener: LocalListener, @@ -517,6 +345,14 @@ impl HeadlessServer { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + client_shell_boot_id: format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ), sent_window_title: None, api_window_title: None, server_keybindings, @@ -540,8 +376,7 @@ impl HeadlessServer { /// Runs the headless server event loop until shutdown. /// - /// This is the server's main loop — analogous to `App::run()` but without - /// a real terminal. It: + /// This is the server's main runtime loop. It: /// - Drains internal events (pane death, state changes) /// - Drains API requests (from the JSON socket) /// - Accepts new client connections @@ -556,10 +391,6 @@ impl HeadlessServer { let quit_notify = self.server_event_tx.clone(); ctrlc_handler(should_quit, quit_notify); - // No input_rx needed — server doesn't read stdin. - // We use None for input_rx so the event loop doesn't try to read from stdin. - self.app.input_rx = None; - let mut needs_render = true; let mut needs_full_render = true; let mut needs_graphics_render = false; @@ -671,12 +502,6 @@ impl HeadlessServer { crate::render_prof::event("full_render_cause.scheduled_tasks"); } - if self.handle_deferred_requests_headless() { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - self.poll_pending_alt_screen_reads(now); if self.process_deferred_alt_screen_reads() { needs_render = true; @@ -684,14 +509,14 @@ impl HeadlessServer { needs_graphics_render = false; } - if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() { + if latest_shell_client(&self.clients).is_some() && self.app.ensure_default_workspace() { needs_render = true; needs_full_render = true; needs_graphics_render = false; crate::render_prof::event("full_render_cause.default_workspace"); } - if self.app.pane_graphics.retain_live_panes(&self.app.state) { + if self.retain_live_pane_graphics() { needs_render = true; needs_graphics_render = true; } @@ -703,7 +528,7 @@ impl HeadlessServer { self.drain_client_config_reload_request(); self.sync_immediate_pty_sources(); self.stream_host_mouse_capture_mode(); - self.stream_host_keyboard_enhancement_flags(); + self.stream_direct_terminal_keyboard_mode(); // 7. Render virtually and stream frames. Hidden-only PTY work keeps a // bounded classification cadence without delaying presentation work @@ -747,53 +572,22 @@ impl HeadlessServer { needs_render = false; continue; } - if needs_full_render { - crate::render_prof::event("retained_gate.needs_full_render"); - } else if !pty_dirty { - crate::render_prof::event("retained_gate.not_pty_dirty"); - } - let pty = if !pty_dirty { - PtyRenderState::Clean - } else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources) + let hidden_only = pty_dirty + && !needs_full_render + && !needs_graphics_render + && !self.pty_sources_visible_to_any_render_target(&render_request.pty_sources); + if hidden_only { + crate::render_prof::event("render.skipped.hidden_sources"); + } else if !needs_full_render + && !needs_graphics_render + && self.render_retained_pane_surface_and_stream(&render_request.pty_sources) { - PtyRenderState::Visible + crate::render_prof::event("retained_surface.invoke"); } else { - PtyRenderState::Hidden - }; - let mut deferred_graphics = false; - let render_plan = retained_render_plan(RetainedRenderInput { - needs_full_render, - needs_graphics_render, - pty, - }); - let rendered_retained = match render_plan { - RetainedRenderPlan::Full => false, - RetainedRenderPlan::Graphics => { - match self.render_retained_graphics_update_and_stream() { - RetainedGraphicsOutcome::Sent => true, - RetainedGraphicsOutcome::Deferred => { - deferred_graphics = true; - false - } - RetainedGraphicsOutcome::Fallback => false, - } - } - RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(), - RetainedRenderPlan::HiddenPty => { - crate::render_prof::event("render.skipped.hidden_sources"); - true - } - }; - if deferred_graphics { - needs_render = false; - continue; - } - if !rendered_retained { crate::render_prof::event("full_render.invoke"); self.render_and_stream(); } - self.app - .record_render_attempt(now, render_plan != RetainedRenderPlan::HiddenPty); + self.app.record_render_attempt(now, !hidden_only); needs_render = false; needs_full_render = false; needs_graphics_render = false; @@ -841,7 +635,10 @@ impl HeadlessServer { LoopEvent::Internal(ev) => { self.handle_internal_event_with_forwarding(ev); } - LoopEvent::ServerEvent(ServerEvent::ClientConnected { writer, .. }) => { + LoopEvent::ServerEvent( + ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. }, + ) => { if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), @@ -915,7 +712,7 @@ impl HeadlessServer { } // Save session on exit. - if !self.app.no_session { + if self.app.policy.persist_session { self.app.save_session_now(); } @@ -923,175 +720,6 @@ impl HeadlessServer { Ok(()) } - fn handle_deferred_requests_headless(&mut self) -> bool { - let mut needs_render = false; - - if self.app.state.request_complete_onboarding { - self.app.state.request_complete_onboarding = false; - self.app.open_settings_from_onboarding(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_onboarding"); - } - - if self.app.state.request_new_workspace { - self.app.state.request_new_workspace = false; - let response = self.headless_workspace_create("headless.workspace.create", None, None); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace" - ); - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_new_workspace"); - } - - if self.app.state.request_new_tab { - self.app.state.request_new_tab = false; - let label = self.app.state.requested_new_tab_name.take(); - let response = self.headless_tab_create("headless.tab.create", label); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create tab" - ); - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_new_tab"); - } - - if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() { - self.app.open_new_linked_worktree_dialog(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { - self.app.open_existing_worktree_dialog(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { - let response = self.headless_workspace_create( - "headless.workspace.create_cwd", - Some(cwd.display().to_string()), - None, - ); - if let Err(error) = response { - error!( - code = %error.code, - message = %error.message, - "failed to create workspace at requested cwd" - ); - self.app.state.mode = app::Mode::Navigate; - } - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_workspace_cwd"); - } - - if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() { - self.app.open_remove_linked_worktree_confirmation(ws_idx); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); - } - - if self.app.state.request_submit_worktree_create { - self.app.state.request_submit_worktree_create = false; - self.app.submit_worktree_create_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_submit_worktree_open { - self.app.state.request_submit_worktree_open = false; - self.app.submit_worktree_open_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_submit_worktree_remove { - self.app.state.request_submit_worktree_remove = false; - self.app.submit_worktree_remove_via_api(); - needs_render = true; - crate::render_prof::event("full_render_cause.deferred_worktree_submit"); - } - - if self.app.state.request_reload_config { - self.app.state.request_reload_config = false; - self.reload_server_config(true); - needs_render = true; - crate::render_prof::event("full_render_cause.config_reload"); - } - - needs_render - } - - fn headless_workspace_create( - &mut self, - id: &'static str, - cwd: Option, - label: Option, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams { - cwd, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn headless_tab_create( - &mut self, - id: &'static str, - label: Option, - ) -> Result<(), api::schema::ErrorBody> { - self.dispatch_headless_runtime_mutation( - id, - api::schema::Method::TabCreate(api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }), - ) - } - - fn dispatch_headless_runtime_mutation( - &mut self, - id: &'static str, - method: api::schema::Method, - ) -> Result<(), api::schema::ErrorBody> { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - self.handle_api_request_with_shutdown_check_inner( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - true, - ); - match response_rx.recv_timeout(Duration::from_secs(5)) { - Ok(response) => serde_json::from_str::(&response) - .map(|response| Err(response.error)) - .unwrap_or(Ok(())), - Err(err) => Err(api::schema::ErrorBody { - code: "internal_error".into(), - message: format!("headless runtime mutation response failed: {err}"), - }), - } - } - fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); @@ -1119,14 +747,31 @@ impl HeadlessServer { let Some(client) = self.clients.get(&client_id) else { return; }; + let client_shell = matches!(client.mode, ClientConnectionMode::ClientShell); + let cell_size = client.cell_size; let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); - if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { + if client_shell { + let cell_size = if cell_size.is_known() { + cell_size + } else { + Default::default() + }; + self.app.state.view.terminal_area = area; + let _ = resize_client_shell_popup_runtime(&self.app, area, cell_size); + crate::ui::compute_tab_surface( + &self.app.state, + &self.app.terminal_runtimes, + area, + true, + cell_size, + ); + } else if self.app.state.kitty_graphics_enabled && cell_size.is_known() { crate::ui::compute_view_with_cell_size( &mut self.app.state, &self.app.terminal_runtimes, area, - client.cell_size, + cell_size, ); } else { crate::ui::compute_view_with_runtime_registry( @@ -1158,11 +803,11 @@ impl HeadlessServer { } } - fn sync_headless_view_geometry(&mut self) { + fn sync_runtime_view_geometry(&mut self) { crate::ui::compute_view_without_resizing_panes( &mut self.app.state, &self.app.terminal_runtimes, - Rect::new(0, 0, self.headless_size.0, self.headless_size.1), + Rect::new(0, 0, self.effective_size.0, self.effective_size.1), ); } @@ -1180,7 +825,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1191,7 +836,7 @@ impl HeadlessServer { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); - self.sync_headless_view_geometry(); + self.sync_runtime_view_geometry(); let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); self.sync_visible_server_config_diagnostic(false); @@ -1199,7 +844,6 @@ impl HeadlessServer { }; let terminal_size = client.terminal_size; - let outer_terminal_focus = client.outer_terminal_focus; let host_cell_size = if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { client.cell_size @@ -1209,18 +853,15 @@ impl HeadlessServer { let host_terminal_theme = client.host_terminal_theme; let host_terminal_appearance = client.host_terminal_appearance; let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit; - let uses_local_keybindings = client.keybindings.is_some(); - let keybindings = client - .keybindings - .as_deref() - .unwrap_or(&self.server_keybindings) - .clone(); + let outer_terminal_focus = client.outer_terminal_focus; self.effective_size = terminal_size; + self.sync_runtime_view_geometry(); self.app.state.outer_terminal_focus = outer_terminal_focus; self.app.state.host_cell_size = host_cell_size; - apply_keybindings(&mut self.app, &keybindings); - self.sync_visible_server_config_diagnostic(uses_local_keybindings); + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); + self.sync_visible_server_config_diagnostic(false); if outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } @@ -1231,230 +872,6 @@ impl HeadlessServer { self.app.set_host_terminal_theme(host_terminal_theme); } - #[cfg(unix)] - fn perform_live_handoff( - &mut self, - params: crate::api::schema::ServerLiveHandoffParams, - ) -> io::Result<()> { - info!("starting live handoff"); - let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from); - let socket_path = crate::server::handoff::handoff_socket_path(); - let token = format!( - "{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let listener = match crate::server::handoff::bind_listener(&socket_path) { - Ok(listener) => listener, - Err(err) => { - self.handoff_in_progress = false; - return Err(err); - } - }; - - let mut pane_by_terminal = HashMap::new(); - for ws in &self.app.state.workspaces { - for tab in &ws.tabs { - for (pane_id, pane) in &tab.panes { - pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw()); - } - } - } - 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(); - let _ = reject_pending_client_connections(&self.client_listener); - - let mut paused_terminal_ids = Vec::new(); - for terminal_id in pane_by_terminal.keys() { - if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { - if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - paused_terminal_ids.push(terminal_id.clone()); - } - } - - let snapshot = crate::persist::capture( - &self.app.state.workspaces, - &self.app.state.terminals, - &self.app.terminal_runtimes, - self.app.state.active, - self.app.state.selected, - self.app.state.sidebar_width, - self.app.state.sidebar_section_split, - self.app.state.collapsed_space_keys.clone(), - ); - - let mut handoff_entries = Vec::new(); - for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { - let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { - continue; - }; - let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); - let has_agent_session = self - .app - .state - .terminals - .get(terminal_id) - .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); - if !has_agent_session { - handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); - } - handoff_entries.push((terminal_id.clone(), handoff_runtime)); - } - - let panes = handoff_entries - .iter() - .map(|(_, runtime)| runtime.clone()) - .collect(); - let manifest = crate::server::handoff::manifest_for( - snapshot, - panes, - params.expected_protocol, - params.expected_version, - self.api_window_title.clone(), - ); - let mut import_child = match crate::server::handoff::spawn_handoff_import( - import_exe.as_deref(), - &socket_path, - &token, - ) { - Ok(child) => child, - Err(err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - let child_pid = import_child.id(); - info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); - - let mut fds = Vec::new(); - let duplicate_result = (|| { - for (terminal_id, _) in &handoff_entries { - let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { - continue; - }; - fds.push(runtime.duplicate_handoff_fd()?); - } - Ok::<(), io::Error>(()) - })(); - if let Err(err) = duplicate_result { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - let mut stream = match crate::server::handoff::accept_and_validate_on( - listener, - &socket_path, - &token, - &manifest, - ) { - Ok(stream) => stream, - Err(err) => { - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - }; - - let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); - for fd in fds { - let _ = unsafe { libc::close(fd) }; - } - if let Err(err) = send_result { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(err); - } - - if let Some(api_server) = &self.api_server { - let _ = api_server.remove_socket_file_if_owned(); - } else { - let _ = std::fs::remove_file(crate::api::socket_path()); - } - let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); - if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(io::Error::other(format!( - "handoff replacement server did not become ready: {err}" - ))); - } - if let Err(err) = crate::server::handoff::report_committed(&mut stream) { - crate::server::handoff::cleanup_failed_import_child(&mut import_child); - match self.wait_then_restore_public_sockets_after_failed_handoff() { - Ok(()) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - } - Err(restore_err) => { - self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); - return Err(io::Error::other(format!( - "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" - ))); - } - } - return Err(err); - } - - for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { - if !pane_by_terminal.contains_key(&terminal_id) { - continue; - } - debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); - runtime.preserve_for_handoff(); - } - crate::server::handoff::wait_owned_ack(&mut stream); - - Ok(()) - } - - fn finish_live_handoff_shutdown(&mut self) { - self.shutting_down = true; - self.app.state.should_quit = true; - self.app.no_session = true; - info!("live handoff completed; old server exiting"); - } - - #[cfg(not(unix))] - fn perform_live_handoff( - &mut self, - _params: crate::api::schema::ServerLiveHandoffParams, - ) -> io::Result<()> { - Err(io::Error::other("live handoff is only supported on Unix")) - } - fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) { let visible = if uses_local_keybindings { &self.server_config_diagnostic_without_keybindings @@ -1468,68 +885,6 @@ impl HeadlessServer { } } - #[cfg(unix)] - fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let api_tx = self - .api_tx - .clone() - .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; - let api_server = api::start_server_with_stop_control( - api_tx, - self.app.event_hub.clone(), - self.should_quit.clone(), - )?; - - let client_path = client_socket_path(); - prepare_socket_path(&client_path)?; - let listener = bind_local_listener(&client_path)?; - restrict_socket_permissions(&client_path)?; - let client_socket_identity = socket_file_identity(&client_path)?; - listener.set_nonblocking(ListenerNonblockingMode::Accept)?; - - self.api_server = Some(api_server); - self.client_listener = listener; - self.client_socket_path = client_path; - self.client_socket_identity = client_socket_identity; - Ok(()) - } - - #[cfg(unix)] - fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { - let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); - wait_for_old_public_sockets_to_close(timeout)?; - self.restore_public_sockets_after_failed_handoff() - } - - #[cfg(unix)] - fn rollback_handoff_before_commit( - &mut self, - socket_path: &Path, - paused_terminal_ids: &[crate::terminal::TerminalId], - ) { - for terminal_id in paused_terminal_ids { - if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { - runtime.set_handoff_reader_paused(false); - } - } - self.handoff_in_progress = false; - let _ = std::fs::remove_file(socket_path); - } - - #[cfg(unix)] - fn nudge_handoff_panes_on_first_client_attach(&mut self) { - if !self.pending_handoff_repaint_nudge { - return; - } - self.pending_handoff_repaint_nudge = false; - self.app - .terminal_runtimes - .nudge_child_redraw_after_handoff(); - } - - #[cfg(not(unix))] - fn nudge_handoff_panes_on_first_client_attach(&mut self) {} - fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport { let server_keybindings = self.server_keybindings.clone(); apply_keybindings(&mut self.app, &server_keybindings); @@ -1572,7 +927,7 @@ impl HeadlessServer { } fn promote_latest_remaining_client(&mut self) -> bool { - let next_foreground = latest_app_client(&self.clients); + let next_foreground = latest_shell_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; self.sync_foreground_client_state(); @@ -1582,7 +937,7 @@ impl HeadlessServer { fn app_client_count(&self) -> usize { self.clients .values() - .filter(|client| client.is_full_app_client() && client.writer.is_some()) + .filter(|client| client.is_shell_client() && client.writer.is_some()) .count() } @@ -1590,7 +945,10 @@ impl HeadlessServer { self.app_client_count() == 1 && self.foreground_client_id.is_some_and(|id| { self.clients.get(&id).is_some_and(|client| { - client.is_full_app_client() && client.writer.is_some() && client.direct_graphics + client.is_shell_client() + && client.writer.is_some() + && client.direct_graphics + && client.pixel_mouse }) }) } @@ -1602,10 +960,9 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { self.retire_direct_graphics_for_client(client_id); let was_foreground = self.foreground_client_id == Some(client_id); - self.app.clear_input_source(client_id); - self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); - if let Some(removed) = removed { + if let Some(mut removed) = removed { + self.release_client_shell_inputs(client_id, &mut removed); crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); @@ -1624,6 +981,36 @@ impl HeadlessServer { } } + fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) { + for held in client.drain_shell_held_inputs() { + let result = match held.target { + ClientShellInputTarget::Pane(pane_id) => { + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + continue; + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + continue; + }; + apply_client_pane_input_events(runtime, &[held.release]) + } + ClientShellInputTarget::Popup(terminal_id) => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + continue; + }; + apply_client_popup_input_events(runtime, &[held.release]) + } + }; + if let Err(err) = result { + warn!(client_id, err = %err, "client shell teardown release failed"); + } + } + } + fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { if self.foreground_client_id == Some(client_id) { return true; @@ -1645,77 +1032,6 @@ impl HeadlessServer { } } - fn send_client_graphics_cleanup(&mut self, client_id: u64) { - let (writer, bytes) = match self.clients.get_mut(&client_id) { - Some(client) => { - let bytes = client.graphics_cache.clear_bytes(); - (client.writer.as_ref().cloned(), bytes) - } - None => return, - }; - if bytes.is_empty() { - return; - } - let Some(writer) = writer else { - return; - }; - let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else { - return; - }; - writer.replace_with_cleanup(serialized); - } - - fn send_all_clients_graphics_cleanup(&mut self) { - let client_ids = self.clients.keys().copied().collect::>(); - for client_id in client_ids { - self.send_client_graphics_cleanup(client_id); - } - } - - fn update_client_host_theme_from_events( - &mut self, - client_id: u64, - events: &[crate::raw_input::RawInputEvent], - ) -> bool { - let Some(client) = self.clients.get_mut(&client_id) else { - return false; - }; - - if !client.update_host_theme_from_events(events) { - return false; - } - - if self.foreground_client_id == Some(client_id) { - let mut changed = self.app.set_host_terminal_appearance_state( - client.host_terminal_appearance, - client.host_terminal_appearance_explicit, - ); - changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); - if changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - changed - } else { - false - } - } - - fn update_client_outer_focus_from_events( - &mut self, - client_id: u64, - events: &[crate::raw_input::RawInputEvent], - ) { - let Some(client) = self.clients.get_mut(&client_id) else { - return; - }; - let Some(next_focus) = client.update_outer_focus_from_events(events) else { - return; - }; - if self.foreground_client_id == Some(client_id) { - self.app.state.outer_terminal_focus = Some(next_focus); - } - } - /// Accepts pending client connections from the non-blocking listener. #[cfg(unix)] fn accept_client_connections(&mut self) -> io::Result<()> { @@ -1766,7 +1082,9 @@ impl HeadlessServer { async fn reject_late_client_connections(&mut self) { self.server_event_rx.close(); while let Some(event) = self.server_event_rx.recv().await { - if let ServerEvent::ClientConnected { writer, .. } = event { + if let ServerEvent::ClientConnected { writer, .. } + | ServerEvent::ClientShellConnected { writer, .. } = event + { if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }) { @@ -1803,47 +1121,142 @@ impl HeadlessServer { .map(|resolved| resolved.terminal_id) } - fn write_client_clipboard_image( - &mut self, + fn client_clipboard_image_target_is_valid( + &self, + client_id: u64, + target: &protocol::ClientClipboardImageTarget, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::TerminalAttach { .. }) + }) + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + !self.handoff_in_progress + && self.app.state.popup_pane.is_none() + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self.app.parse_pane_id(pane_id).is_some() + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + !self.handoff_in_progress + && self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + && self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id) + } + } + } + + fn stage_client_clipboard_image( + &self, client_id: u64, extension: &str, data: &[u8], - ) -> std::io::Result { + ) -> std::io::Result { let staged = crate::server::clipboard_image::stage(client_id, extension, data)?; - if let Some(client) = self.clients.get_mut(&client_id) { - client.staged_clipboard_files.push(staged.path); - } info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image"); - Ok(staged.paste_text) + Ok(staged) } - fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool { - if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalAttach { terminal_id }, - .. - }) = self.clients.get(&client_id) - { - if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - let payload = paste_payload_for_runtime(runtime, &path); - if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { - warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); + fn paste_client_clipboard_image_path( + &mut self, + client_id: u64, + target: protocol::ClientClipboardImageTarget, + path: String, + ) -> bool { + match target { + protocol::ClientClipboardImageTarget::DirectTerminal => { + let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + else { + return false; + }; + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + let payload = paste_payload_for_runtime(runtime, &path); + if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed"); + } } + true + } + protocol::ClientClipboardImageTarget::Pane(pane_id) => { + if self.handoff_in_progress + || self.app.state.popup_pane.is_some() + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + return false; + }; + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return foreground_changed; + }; + if let Err(err) = apply_client_pane_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, pane_id, err = %err, "client shell clipboard image paste failed"); + } + true + } + protocol::ClientClipboardImageTarget::Popup(terminal_id) => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let Some(popup_terminal_id) = self + .app + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return false; + }; + if popup_terminal_id.as_str() != terminal_id { + return false; + } + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return foreground_changed; + }; + if let Err(err) = apply_client_popup_input_events( + runtime, + &[protocol::ClientPaneInputEvent::Paste(path)], + ) { + warn!(client_id, terminal_id, err = %err, "client shell popup clipboard image paste failed"); + } + true } - return true; } - - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - if let Some(client) = self.clients.get_mut(&client_id) { - client.request_semantic_redraw_after_input(); - } - self.app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste(path)], - self.foreground_client_id == Some(client_id), - ); - true } fn resolve_terminal_session_target( @@ -1897,7 +1310,6 @@ impl HeadlessServer { client.mode = ClientConnectionMode::TerminalObserve { terminal_id: terminal_id.clone(), }; - client.pending_terminal_attach = false; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); @@ -1947,230 +1359,54 @@ impl HeadlessServer { true } - fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { - self.app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .map(|terminal| terminal.state) - }) - }) - .unwrap_or(crate::detect::AgentState::Unknown) - } - - fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option { - self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - .map(str::to_string) - }) - }) - } - - fn forward_pane_state_update_notifications_to_clients( + fn handle_terminal_attach_mouse( &mut self, - update: &crate::app::actions::PaneStateUpdate, - ) { - if self.app.state.toast_config.delay_seconds != 0 { - return; + client_id: u64, + kind: protocol::ClientMouseKind, + position: protocol::ClientMousePosition, + geometry: Option, + modifiers: u8, + lines: u16, + ) -> bool { + if self.handoff_in_progress { + return false; } - - let is_active_tab = self - .app - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - return; - } - let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( - suppress_active_tab_notifications, - update, + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return false; + }; + let terminal_id = terminal_id.clone(); + let terminal_size = client.terminal_size; + let cell_size = client.cell_size; + let pixel_mouse = client.pixel_mouse; + let host_sgr_pixels_active = client.host_sgr_pixels_active == Some(true); + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + return false; + }; + let Some(position) = terminal_attach_mouse_position( + runtime, + terminal_size, + cell_size, + pixel_mouse, + host_sgr_pixels_active, + position, + geometry, ) else { - return; + return false; }; - let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { - return; - }; - let Some(agent_label) = update.agent_label.as_deref() else { - return; - }; - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => "needs attention", - crate::app::state::ToastKind::Finished => "finished", - crate::app::state::ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); - let context = crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - ); - self.send_notify_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - format!("{agent_label} {event_text}"), - non_empty_body(&context), - ); - } - - fn forward_agent_notification_delivery( - &mut self, - delivery: &crate::app::state::AgentNotificationDelivery, - ) { - if let Some(sound) = delivery.sound { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - - if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if let Some(toast) = &delivery.client_notification { - self.send_notify_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - &toast.title, - non_empty_body(&toast.context), - ); - } - } - } - - fn send_notify_to_foreground_client( - &mut self, - kind: protocol::NotifyKind, - message: impl Into, - body: Option, - ) -> bool { - self.send_to_foreground_client(ServerMessage::Notify { + let event = protocol::ClientPaneInputEvent::Mouse { kind, - message: message.into(), - body, - }) - } - - fn send_flat_toast_to_foreground_client( - &mut self, - kind: protocol::NotifyKind, - message: impl AsRef, - ) -> bool { - let (title, body) = crate::terminal_notify::split_message(message.as_ref()); - self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) - } - - fn handle_notification_show_api( - &mut self, - id: String, - params: api::schema::NotificationShowParams, - ) -> String { - use api::schema::{NotificationShowReason, ResponseResult}; - - let Some(title) = sanitize_notification_text(¶ms.title, 80) else { - return serde_json::to_string(&api::schema::ErrorResponse { - id, - error: api::schema::ErrorBody { - code: "invalid_params".into(), - message: "notification title is empty".into(), - }, - }) - .unwrap_or_else(|_| "{}".to_string()); + position, + geometry: None, + modifiers, + lines: lines.max(1), }; - - match self.app.state.toast_config.delivery { - config::ToastDelivery::Off => { - return serde_json::to_string(&api::schema::SuccessResponse { - id, - result: ResponseResult::NotificationShow { - shown: false, - reason: NotificationShowReason::Disabled, - }, - }) - .unwrap_or_else(|_| "{}".to_string()); - } - config::ToastDelivery::Herdr => { - let sound = params.sound; - let response = self.app.handle_api_request_after_internal_events_drained( - api::schema::Request { - id, - method: api::schema::Method::NotificationShow(params), - }, - ); - if notification_show_response_shown(&response) { - self.forward_api_notification_sound(sound); - } - return response; - } - config::ToastDelivery::Terminal | config::ToastDelivery::System => {} + if let Err(err) = apply_client_pane_input_events(runtime, &[event]) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach mouse input failed"); } - - let body = params - .body - .as_deref() - .and_then(|body| sanitize_notification_text(body, 240)); - if self.app.api_notification_rate_limited(Instant::now()) { - return serde_json::to_string(&api::schema::SuccessResponse { - id, - result: ResponseResult::NotificationShow { - shown: false, - reason: NotificationShowReason::RateLimited, - }, - }) - .unwrap_or_else(|_| "{}".to_string()); - } - let kind = toast_notify_kind(self.app.state.toast_config.delivery) - .expect("terminal/system delivery has notify kind"); - let shown = self.send_notify_to_foreground_client(kind, title, body); - if shown { - self.app.mark_api_notification_shown(Instant::now()); - self.forward_api_notification_sound(params.sound); - } - let reason = if shown { - NotificationShowReason::Shown - } else { - NotificationShowReason::NoForegroundClient - }; - - serde_json::to_string(&api::schema::SuccessResponse { - id, - result: ResponseResult::NotificationShow { shown, reason }, - }) - .unwrap_or_else(|_| "{}".to_string()) + true } /// Pulls only titles reported dirty by the PTY parser. A focused pane title @@ -2292,367 +1528,6 @@ impl HeadlessServer { .unwrap_or_else(|_| "{}".to_string()) } - fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) { - let Some(sound) = sound.to_sound() else { - return; - }; - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - - /// Handles a single internal event with forwarding logic for clipboard, - /// sound, and toast notifications to connected clients. - /// - /// ALL internal events MUST be routed through this method to ensure - /// clipboard/notify forwarding is never bypassed. Do not call - /// `self.app.handle_internal_event()` directly for any internal event - /// in the headless server — use this method instead. - /// - /// Returns true if the event changed visual state (requiring a re-render). - fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { - match &ev { - AppEvent::TerminalBell { pane_id, count } => { - if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { - debug!( - pane = pane_id.raw(), - count, "dropped terminal bell without a foreground client" - ); - } - false - } - AppEvent::ClipboardWrite { content } => { - // Clipboard writes are client-local side effects. Forward them only to - // the foreground client instead of broadcasting to every attached client. - let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); - if self.send_to_foreground_client(ServerMessage::Clipboard { data }) { - self.app.show_clipboard_feedback(); - } - true - } - AppEvent::PrefixInputSource { active } => { - // Input-source switching is a client-local host side effect; forward it to the - // foreground client (which owns the real TIS switch + run-loop pump), like clipboard. - self.send_to_foreground_client(ServerMessage::PrefixInputSource { - active: *active, - }); - true - } - AppEvent::StateChanged { pane_id, agent, .. } => { - // Capture toast before handling. - let toast_before = self.app.state.toast.clone(); - let pane_id_val = *pane_id; - let agent_val = *agent; - - // Find the previous effective state of this pane before the event - // is processed. Notifications must follow effective state changes, - // not raw fallback reports that may be masked by hook authority. - let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); - - // Handle the state change (updates pane state, sets toast on AppState). - // Headless mode disables local sound playback separately from the - // sound policy so reloads can keep server-side notification policy live. - self.sync_foreground_client_state(); - let suppress_completion = self - .app - .handle_internal_event_with_pane_updates(ev) - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - - // Forward sound notification to clients when server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::HookStateReported { - pane_id, - agent_label, - .. - } => { - // Hook reports can be stale or no-op after sequence rejection. - // Forward only effective state changes observed after handling. - let toast_before = self.app.state.toast.clone(); - let pane_id_val = *pane_id; - let agent_val = crate::detect::parse_agent_label(agent_label); - - // Capture the previous effective state for this pane. Hook reports - // are already folded into pane.state; raw hook transitions must not - // produce a second notification path. - let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); - - self.sync_foreground_client_state(); - let suppress_completion = self - .app - .handle_internal_event_with_pane_updates(ev) - .iter() - .any(|update| update.pane_id == pane_id_val && update.suppress_completion); - - // Forward sound notification based on the effective transition when - // server-side sound policy allows it. - let is_active_tab = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .is_some_and(|ws| { - ws.find_tab_index_for_pane(pane_id_val) - .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) - }); - - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); - - if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && self.app.state.sound.allows(agent_val) - { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { - self.send_notify_to_foreground_client( - protocol::NotifyKind::Sound, - sound_notify_message(sound), - None, - ); - } - } - - let toast_msg = if !suppress_completion - && self.app.state.toast_config.delay_seconds == 0 - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - toast_message_from_state_change( - &self.app.state, - &self.app.terminal_runtimes, - pane_id_val, - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - ) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::UpdateReady { - version, - install_command, - } => { - let toast_before = self.app.state.toast.clone(); - let version = version.clone(); - let install_command = install_command.clone(); - - self.app.handle_internal_event(ev); - - let toast_msg = - if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - self.app - .state - .toast - .as_ref() - .map(|toast| format!("{}: {}", toast.title, toast.context)) - } else { - Some(format!( - "v{version} available: {}", - crate::update::update_install_instruction(&install_command) - )) - } - } else { - None - }; - - if let Some(msg) = toast_msg { - self.send_flat_toast_to_foreground_client( - toast_notify_kind(self.app.state.toast_config.delivery) - .expect("toast forwarding requires a client notification kind"), - msg, - ); - } - - true - } - AppEvent::PaneDied { pane_id } => { - let pane_id_val = *pane_id; - let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - tab.panes - .get(pane_id) - .map(|pane| pane.attached_terminal_id.to_string()) - }) - }); - if let Some(update) = self - .app - .state - .publish_pane_process_exit_if_agent(pane_id_val) - { - self.app.emit_pane_state_update(&update); - self.forward_pane_state_update_notifications_to_clients(&update); - } - - self.app.handle_internal_event(ev); - - if self.app.find_pane(pane_id_val).is_none() { - if let Some(terminal_id) = terminal_id { - self.shutdown_terminal_stream_clients( - &terminal_id, - format!("terminal {terminal_id} exited"), - ); - } - } - - true - } - _ => self.app.handle_internal_event_with_render_impact(ev), - } - } - - /// Drains internal events, forwarding clipboard, sound, and toast - /// notifications to connected clients instead of processing them locally. - /// - /// In the monolithic mode: - /// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`. - /// - Sound notifications are played locally via `sound::play`. - /// - Toast notifications are set on AppState and rendered into the frame. - /// - /// In the headless server, there is no stdout terminal or audio subsystem, - /// so we: - /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the - /// foreground client only. - /// - Detect when a sound would be played and forward as - /// `ServerMessage::Notify { kind: Sound }` to the foreground client. - /// - Detect when a toast is set on AppState and forward as - /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. - fn drain_internal_events_with_forwarding(&mut self) -> bool { - self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) - .1 - } - - fn drain_all_internal_events_with_forwarding(&mut self) -> bool { - let mut changed = false; - loop { - let (had_event, batch_changed) = - self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT); - changed |= batch_changed; - if !had_event || self.should_quit.load(Ordering::Acquire) { - break; - } - } - changed - } - - fn drain_internal_events_with_forwarding_up_to(&mut self, limit: usize) -> (bool, bool) { - let mut had_event = false; - let mut changed = false; - for _ in 0..limit { - let Ok(ev) = self.app.event_rx.try_recv() else { - break; - }; - had_event = true; - changed |= self.handle_internal_event_with_forwarding(ev); - } - (had_event, changed) - } - fn drain_client_config_reload_request(&mut self) { if !self.app.state.request_client_config_reload { return; @@ -2710,6 +1585,39 @@ impl HeadlessServer { } } + /// Sends an ephemeral semantic event to every connected client-rendered shell. + fn send_to_client_shells(&mut self, msg: ServerMessage) -> bool { + let serialized = match Self::frame_server_message(&msg) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize message for client shells"); + return false; + } + }; + let client_ids = self + .clients + .iter() + .filter_map(|(&client_id, client)| { + matches!(client.mode, ClientConnectionMode::ClientShell).then_some(client_id) + }) + .collect::>(); + let mut sent = false; + for client_id in client_ids { + let Some(client) = self.clients.get(&client_id) else { + continue; + }; + let Some(writer) = &client.writer else { + continue; + }; + if writer.control.send(serialized.clone()).is_ok() { + sent = true; + } else { + self.remove_client_and_resize_if_needed(client_id); + } + } + sent + } + /// Sends a client-local side effect to the foreground client only. fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool { let Some(client_id) = self.foreground_client_id else { @@ -2781,7 +1689,6 @@ impl HeadlessServer { fn disconnect_all_clients_for_handoff(&mut self) { let client_ids = self.clients.keys().copied().collect::>(); for client_id in client_ids { - self.send_client_graphics_cleanup(client_id); self.send_to_client( client_id, ServerMessage::ServerShutdown { @@ -2883,7 +1790,6 @@ impl HeadlessServer { client.mode = ClientConnectionMode::TerminalAttach { terminal_id: terminal_id.clone(), }; - client.pending_terminal_attach = false; client.render_state.reset_baseline(); client.last_activity = stamp; let was_foreground = self.foreground_client_id == Some(client_id); @@ -2907,92 +1813,12 @@ impl HeadlessServer { } fn client_is_pending_terminal_mode(&self, client_id: u64) -> bool { - self.clients.get(&client_id).is_some_and(|client| { - client.pending_terminal_attach && matches!(client.mode, ClientConnectionMode::App) - }) + self.clients + .get(&client_id) + .is_some_and(|client| matches!(client.mode, ClientConnectionMode::TerminalPending)) } /// Handles a server event. Returns true if the event requires a re-render. - fn handle_client_input_events( - &mut self, - client_id: u64, - events: Vec, - ) -> bool { - let source_was_foreground = self.foreground_client_id == Some(client_id); - let source_is_full_app = self - .clients - .get(&client_id) - .is_some_and(ClientConnection::is_full_app_client); - let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( - &events, - self.app.state.redraw_on_focus_gained, - ); - let render_neutral_mouse_motion = - events_are_render_neutral_mouse_motion(&events, self.app.state.mode); - if let Some(client) = self.clients.get_mut(&client_id) { - if host_surface_redraw { - client.request_repaint(); - client.defer_full_render(); - } else if !render_neutral_mouse_motion { - // Ensure semantic clients receive one post-input frame even if the - // semantic buffer compares equal. Terminal-ANSI clients must keep their - // server-side blit baseline; resetting it here forces a full redraw on - // every keypress and makes remote sessions feel extremely slow. - client.request_semantic_redraw_after_input(); - } - } - if source_is_full_app { - self.update_client_outer_focus_from_events(client_id, &events); - if events - .iter() - .any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost)) - { - // Focus loss is not a teardown, so the pending URL click stays. - self.app.release_input_source_headless(client_id); - } - } - let events = events_for_app_routing(events, source_was_foreground, source_is_full_app); - let interaction = events_include_interaction(&events); - let foreground_changed = if interaction { - self.promote_client_to_foreground(client_id) - } else { - false - }; - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - let theme_changed = self.update_client_host_theme_from_events(client_id, &events); - // Client-local theme reports were applied above; routing them again would update every - // pane once per palette entry instead of once per captured batch. - self.app.route_client_events_from(client_id, events, false); - if self.app.take_config_reloaded_from_disk() { - self.reload_server_config(false); - } else { - self.sync_foreground_client_state(); - } - - if self.app.state.detach_requested { - self.app.state.detach_requested = false; - info!(client_id, "client detach requested via keybind"); - - self.send_client_graphics_cleanup(client_id); - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some("detached".to_owned()), - }, - ); - - if let Some(client) = self.clients.get_mut(&client_id) { - client.writer = None; - } - - false - } else { - foreground_changed || theme_changed || (interaction && !render_neutral_mouse_motion) - } - } - fn handle_server_event(&mut self, ev: ServerEvent) -> bool { if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) { return false; @@ -3005,11 +1831,8 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, - keybindings, + pixel_mouse, writer, - render_encoding, - direct_attach_requested, - direct_graphics, } => { if self.handoff_in_progress { if let Ok(message) = @@ -3024,38 +1847,100 @@ impl HeadlessServer { } return false; } - let first_app_client = !direct_attach_requested && self.app_client_count() == 0; info!( client_id, - cols, - rows, - cell_width_px, - cell_height_px, - ?render_encoding, - "client connected" + cols, rows, cell_width_px, cell_height_px, "direct terminal client connected" ); let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); let mut connection = ClientConnection::new_with_mode( - ClientConnectionMode::App, - keybindings, + ClientConnectionMode::TerminalPending, (cols, rows), - crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }, - crate::terminal_theme::TerminalTheme::default(), - None, + observed, last_activity, - render_encoding, - direct_attach_requested, + protocol::RenderEncoding::TerminalAnsi, Some(writer), ); - connection.direct_graphics = direct_graphics; - connection.pixel_mouse = direct_graphics; + connection.pixel_mouse = pixel_mouse; self.clients.insert(client_id, connection); - if !direct_attach_requested { - self.foreground_client_id = Some(client_id); + false + } + ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + writer, + } => { + if self.handoff_in_progress { + if let Ok(message) = + Self::frame_server_message(&ServerMessage::ServerShutdown { + reason: Some( + "live update in progress; reconnect after handoff completes" + .to_owned(), + ), + }) + { + let _ = writer.control.send(message); + } + return false; } + info!( + client_id, + cols = surface_cols, + rows = surface_rows, + cell_width_px, + cell_height_px, + render_encoding = ?protocol::RenderEncoding::SemanticFrame, + "client connected" + ); + self.app.ensure_default_workspace(); + let first_app_client = self.app_client_count() == 0; + let last_activity = self.allocate_activity_stamp(); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let mut connection = ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (surface_cols, surface_rows), + observed, + last_activity, + protocol::RenderEncoding::SemanticFrame, + Some(writer), + ); + connection.pixel_mouse = pixel_mouse && observed.is_known(); + connection.direct_graphics = direct_graphics; + connection.shell_uses_endpoint_keybindings = endpoint_keybindings; + connection.shell_mouse_capture = mouse_capture; + connection.shell_projection_revision = 1; + let config_diagnostic = if endpoint_keybindings { + self.server_config_diagnostic.as_deref() + } else { + self.server_config_diagnostic_without_keybindings.as_deref() + }; + let snapshot = client_shell_snapshot( + &self.app, + &self.client_shell_boot_id, + connection.shell_projection_revision, + config_diagnostic, + ); + connection.shell_snapshot = Some(snapshot.clone()); + self.clients.insert(client_id, connection); + self.send_to_client( + client_id, + ServerMessage::ClientShellSnapshot(Box::new(snapshot)), + ); + self.foreground_client_id = Some(client_id); if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } @@ -3099,120 +1984,65 @@ impl HeadlessServer { } => self.handle_terminal_attach_scroll( client_id, source, direction, lines, column, row, modifiers, ), - ServerEvent::ClientInputPixels { + ServerEvent::ClientAttachMouse { client_id, - data, + kind, + position, geometry, - } => { - let coordinates_valid = crate::input::mouse::parse_report(&data) - .and_then(|(x, y)| geometry.cell(x, y)) - .is_some(); - let valid = coordinates_valid - && self.clients.get(&client_id).is_some_and(|client| { - let cell = client.cell_size; - client.is_full_app_client() - && client.host_sgr_pixels_active == Some(true) - && client.terminal_size == (geometry.cols, geometry.rows) - && cell.is_known() - && cell.width_px == geometry.width_px / u32::from(geometry.cols) - && cell.height_px == geometry.height_px / u32::from(geometry.rows) - }); - if !valid || self.handoff_in_progress || !self.focused_pane_graphics_demand() { - return false; - } - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } - self.app - .route_client_pixel_mouse(client_id, &data, geometry) - || foreground_changed - } + modifiers, + lines, + } => self.handle_terminal_attach_mouse( + client_id, kind, position, geometry, modifiers, lines, + ), ServerEvent::ClientInput { client_id, data } => { if self.handoff_in_progress { debug!( client_id, len = data.len(), - "ignored client input during handoff" + "ignored direct terminal input during handoff" ); return false; } - debug!(client_id, len = data.len(), "client input received"); - if let Some(ClientConnection { + let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, .. }) = self.clients.get(&client_id) - { - if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { - if let Err(err) = apply_terminal_attach_input(runtime, data) { - warn!(client_id, terminal_id = %terminal_id, err = %err); - } - } - return true; - } - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { + else { return false; - } - let events = if let Some(client) = self.clients.get_mut(&client_id) { - let mut events = client.raw_input.push(&data); - // The thin client only forwards a bare ESC after its local input timeout. - if data.as_slice() == b"\x1b" { - events.extend(client.raw_input.flush_timeout()); - } - events - } else { - Vec::new() }; - self.handle_client_input_events(client_id, events) - } - ServerEvent::ClientInputEvents { client_id, events } => { - if self.handoff_in_progress { - debug!( - client_id, - len = events.len(), - "ignored client input events during handoff" - ); - return false; + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + if let Err(err) = apply_terminal_attach_input(runtime, data) { + warn!(client_id, terminal_id = %terminal_id, err = %err); + } } - debug!( - client_id, - len = events.len(), - "client input events received" - ); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { - return false; - } - let events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect(); - self.handle_client_input_events(client_id, events) + true } ServerEvent::ClientPasteRejected { client_id, size, max, } => { - self.send_to_client( - client_id, + let detail = format!("Input message is {size} bytes; Herdr's limit is {max} bytes"); + let message = if matches!( + self.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::ClientShell) + ) { + ServerMessage::ClientShellError { + message: format!("Paste rejected: {detail}"), + } + } else { ServerMessage::Notify { kind: protocol::NotifyKind::Toast, message: "Paste rejected".to_owned(), - body: Some(format!( - "Input message is {size} bytes; Herdr's limit is {max} bytes" - )), - }, - ); + body: Some(detail), + } + }; + self.send_to_client(client_id, message); false } ServerEvent::ClientClipboardImage { client_id, + target, extension, data, } => { @@ -3222,14 +2052,28 @@ impl HeadlessServer { extension = %extension, "client clipboard image received" ); - if matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { .. }) - ) { + if !self.client_clipboard_image_target_is_valid(client_id, &target) { return false; } - match self.write_client_clipboard_image(client_id, &extension, &data) { - Ok(path) => self.paste_client_clipboard_image_path(client_id, path), + match self.stage_client_clipboard_image(client_id, &extension, &data) { + Ok(staged) => { + let routed = self.paste_client_clipboard_image_path( + client_id, + target, + staged.paste_text, + ); + if routed { + if let Some(client) = self.clients.get_mut(&client_id) { + client.staged_clipboard_files.push(staged.path); + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + return false; + } + } else { + crate::server::clipboard_image::remove_files(vec![staged.path]); + } + routed + } Err(err) => { warn!(client_id, err = %err, "failed to stage client clipboard image"); true @@ -3242,27 +2086,29 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, + pixel_mouse, } => { info!( client_id, - cols, rows, cell_width_px, cell_height_px, "client resize" + cols, rows, cell_width_px, cell_height_px, pixel_mouse, "client resize" ); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + let pixel_mouse = pixel_mouse && observed.is_known(); let direct_terminal_id = if let Some(ClientConnection { mode: ClientConnectionMode::TerminalAttach { terminal_id }, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); Some((terminal_id.clone(), *cell_size)) } else { @@ -3275,38 +2121,366 @@ impl HeadlessServer { return true; } if let Some(ClientConnection { - mode: ClientConnectionMode::TerminalObserve { .. }, + mode: + ClientConnectionMode::TerminalObserve { .. } + | ClientConnectionMode::TerminalPending, terminal_size, cell_size, + pixel_mouse: client_pixel_mouse, render_state, .. }) = self.clients.get_mut(&client_id) { *terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - *cell_size = observed; - } + *cell_size = observed; + *client_pixel_mouse = pixel_mouse; render_state.request_repaint(); return true; } - if let Some(client) = self.clients.get_mut(&client_id) { - client.terminal_size = (cols, rows); - let observed = crate::kitty_graphics::HostCellSize { - width_px: cell_width_px, - height_px: cell_height_px, - }; - if observed.is_known() { - client.cell_size = observed; - } + false + } + ServerEvent::ClientShellResize { + client_id, + surface_cols, + surface_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + return false; } + client.terminal_size = (surface_cols, surface_rows); + let observed = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + if observed.is_known() { + client.cell_size = observed; + } + client.pixel_mouse = pixel_mouse && observed.is_known(); + client.request_repaint(); self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true } + ServerEvent::ClientShellHostTheme { client_id, update } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + return false; + } + if !client.update_host_theme(&update) { + return false; + } + if self.foreground_client_id != Some(client_id) { + return false; + } + let mut changed = self.app.set_host_terminal_appearance_state( + client.host_terminal_appearance, + client.host_terminal_appearance_explicit, + ); + changed |= self.app.set_host_terminal_theme(client.host_terminal_theme); + if changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + changed + } + ServerEvent::ClientShellFocus { client_id, focused } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.outer_terminal_focus == Some(focused) + { + return false; + } + client.outer_terminal_focus = Some(focused); + if focused { + self.promote_client_to_foreground(client_id); + self.resize_shared_runtime_to_effective_size_before_input(); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Gained); + true + } else if self.foreground_client_id == Some(client_id) { + self.app.state.outer_terminal_focus = Some(false); + self.app + .send_outer_focus_event(crate::ghostty::FocusEvent::Lost); + true + } else { + false + } + } + ServerEvent::ClientShellMouseCapture { client_id, enabled } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) + || client.shell_mouse_capture == enabled + { + return false; + } + client.shell_mouse_capture = enabled; + client.host_mouse_capture_active = None; + true + } + ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events, + } => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; + let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) + else { + return false; + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return false; + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + if self.app.state.popup_pane.is_some() + || !self + .app + .state + .pane_visible_on_active_surface(workspace_index, runtime_pane_id) + { + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return false; + }; + let releases = events + .into_iter() + .filter(client_pane_input_releases_press) + .collect::>(); + if releases.is_empty() { + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Pane(pane_id.clone()), + &releases, + ); + } + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_pane_input_events(runtime, &releases) { + warn!(client_id, pane_id, err = %err, "targeted client shell release failed"); + } + return runtime.scroll_metrics() != scroll_before; + } + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client + .track_shell_input(ClientShellInputTarget::Pane(pane_id.clone()), &events); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) else { + return foreground_changed; + }; + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_pane_input_events(runtime, &events) { + warn!(client_id, pane_id, err = %err, "targeted client shell input failed"); + } + foreground_changed || runtime.scroll_metrics() != scroll_before + } + ServerEvent::ClientShellPopupInput { + client_id, + terminal_id, + events, + } => { + if self.handoff_in_progress + || !self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + }) + { + return false; + } + let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| { + client.pixel_mouse && client.host_sgr_pixels_active == Some(true) + }); + let mut events = events; + let Some(popup_terminal_id) = self + .app + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return false; + }; + if popup_terminal_id.as_str() != terminal_id { + return false; + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return false; + }; + super::pane_input::downgrade_ineligible_pixel_mouse( + &mut events, + pixel_mouse, + runtime.current_size(), + runtime.pixel_size(), + ); + let interaction = client_pane_input_has_interaction(&events); + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Popup(terminal_id.clone()), + &events, + ); + } + let foreground_changed = + interaction && self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { + return foreground_changed; + }; + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_popup_input_events(runtime, &events) { + warn!(client_id, terminal_id, err = %err, "targeted client popup input failed"); + } + foreground_changed || runtime.scroll_metrics() != scroll_before + } + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request, + } => { + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + self.remove_client_and_resize_if_needed(client_id); + return true; + } + let request_id = request.id.clone(); + if !crate::server::client_commands::supports_client_shell_method(&request.method) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "unsupported_endpoint_command", + "this method is not available through the client shell command lane", + ); + self.send_to_client(client_id, message); + return false; + } + if boot_id != self.client_shell_boot_id { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "stale_boot", + "endpoint command targeted an earlier server boot", + ); + self.send_to_client(client_id, message); + return false; + } + if client.shell_endpoint_command_in_flight { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some("client sent concurrent endpoint commands".into()), + }, + ); + self.remove_client_and_resize_if_needed(client_id); + return true; + } + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = crate::server::client_commands::spawn_response_waiter( + client_id, + boot_id.clone(), + request_id.clone(), + response_rx, + self.server_event_tx.clone(), + ) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "server_unavailable", + format!("failed to start endpoint response bridge: {err}"), + ); + self.send_to_client(client_id, message); + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = true; + } + let foreground_changed = self.promote_client_to_foreground(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + foreground_changed + | self.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: *request, + respond_to, + response_write_complete: None, + stream_active: None, + }) + } + ServerEvent::ClientShellEndpointResponseChunkReady { + client_id, + boot_id, + request_id, + final_chunk, + data, + } => { + let valid = self.clients.get(&client_id).is_some_and(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell) + && client.shell_endpoint_command_in_flight + && boot_id == self.client_shell_boot_id + }); + if !valid { + return false; + } + if final_chunk { + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = false; + } + } + self.send_to_client( + client_id, + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + }, + ); + false + } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); self.send_terminal_stream_detach_shutdown(client_id); @@ -3344,6 +2518,8 @@ impl HeadlessServer { !matches!( ev, ServerEvent::ClientConnected { .. } + | ServerEvent::ClientShellConnected { .. } + | ServerEvent::ClientShellEndpointResponseChunkReady { .. } | ServerEvent::ClientDisconnected { .. } | ServerEvent::ClientWriterDrained { .. } | ServerEvent::QuitSignal @@ -3754,6 +2930,14 @@ impl HeadlessServer { .handle_deferred_worktree_api_request(msg.request, msg.respond_to); return changed | deferred_changed; } + if self.foreground_client_id.is_some_and(|client_id| { + self.clients + .get(&client_id) + .is_some_and(|client| matches!(client.mode, ClientConnectionMode::ClientShell)) + }) { + self.app.state.view.terminal_area = + Rect::new(0, 0, self.effective_size.0, self.effective_size.1); + } let mut response = if matches!( &msg.request.method, api::schema::Method::ServerReloadConfig(_) @@ -3861,13 +3045,9 @@ impl HeadlessServer { let Some(pane_after) = pane_after else { continue; }; + let terminal_id = pane_after.attached_terminal_id.clone(); - let Some(terminal_after) = self - .app - .state - .terminals - .get(&pane_after.attached_terminal_id) - else { + let Some(terminal_after) = self.app.state.terminals.get(&terminal_id) else { continue; }; @@ -3891,6 +3071,15 @@ impl HeadlessServer { agent = ?agent, "pane effective state changed during API request, checking notification" ); + self.forward_semantic_agent_transition( + *ws_idx, + *pane_id, + *prev_state, + new_state, + prev_agent_label.as_deref(), + agent_label.as_deref(), + agent, + ); if !forwarded_toast_from_state && self.app.state.toast_config.delay_seconds == 0 @@ -3909,7 +3098,7 @@ impl HeadlessServer { .app .state .terminals - .get(&pane_after.attached_terminal_id) + .get(&terminal_id) .and_then(|terminal| terminal.effective_agent_label()) { let event_text = match kind { @@ -3960,764 +3149,16 @@ impl HeadlessServer { } } - if !skip_default_workspace && latest_app_client(&self.clients).is_some() { + if !skip_default_workspace && latest_shell_client(&self.clients).is_some() { changed |= self.app.ensure_default_workspace(); } changed } - fn focused_pane_graphics_demand(&self) -> bool { - self.app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .and_then(crate::workspace::Workspace::focused_pane_id) - .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) - } - - fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self - .app - .state - .should_capture_host_mouse_from(&self.app.terminal_runtimes); - let pixel_mouse_requested = self - .clients - .values() - .any(|client| client.is_full_app_client() && client.pixel_mouse); - let sgr_pixels = pixel_mouse_requested - && self.focused_pane_graphics_demand() - && self - .app - .state - .active - .and_then(|ws_idx| { - self.app - .state - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::focused_pane_id) - .and_then(|pane_id| { - self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - pane_id, - ) - }) - }) - .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); - let mut broken_clients: Vec = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() { - continue; - } - let client_sgr_pixels = sgr_pixels && client.pixel_mouse; - if client.host_mouse_capture_active == Some(enabled) - && client.host_sgr_pixels_active == Some(client_sgr_pixels) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { - enabled, - sgr_pixels: client_sgr_pixels, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize mouse capture mode for client"); - continue; - } - }; - if writer.control.send(serialized).is_err() { - debug!( - client_id, - "client writer channel closed during mouse capture update" - ); - broken_clients.push(client_id); - continue; - } - client.host_mouse_capture_active = Some(enabled); - client.host_sgr_pixels_active = Some(client_sgr_pixels); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn stream_host_keyboard_enhancement_flags(&mut self) { - let report_all_keys = self.app.host_keyboard_report_all_requested(); - let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll { - enabled: report_all_keys, - }) { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize keyboard enhancement flags for clients"); - return; - } - }; - - let mut broken_clients = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() - || client.host_keyboard_report_all_active == Some(report_all_keys) - { - continue; - } - let Some(writer) = &client.writer else { - continue; - }; - if writer.control.send(serialized.clone()).is_err() { - debug!( - client_id, - "client writer channel closed during keyboard enhancement update" - ); - broken_clients.push(client_id); - continue; - } - client.host_keyboard_report_all_active = Some(report_all_keys); - } - - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - fn has_pending_presentation_work( - &self, - needs_full_render: bool, - needs_graphics_render: bool, - ) -> bool { - needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() - } - - fn sync_immediate_pty_sources(&self) { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - let mut pane_ids = if has_app_target { - self.app.state.app_surface_pane_ids() - } else { - HashSet::new() - }; - if !direct_terminal_targets.is_empty() { - for workspace in &self.app.state.workspaces { - for tab in &workspace.tabs { - pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { - direct_terminal_targets - .contains(pane.attached_terminal_id.as_str()) - .then_some(pane_id) - })); - } - } - if let Some(popup) = &self.app.state.popup_pane { - if direct_terminal_targets.contains(popup.terminal_id.as_str()) { - pane_ids.insert(popup.pane_id); - } - } - } - self.app.render_dirty.set_immediate_pty_sources(pane_ids); - } - - fn pty_render_targets(&self) -> (bool, HashSet<&str>) { - let mut has_app_target = false; - let mut direct_terminal_targets = HashSet::new(); - for client in self - .clients - .values() - .filter(|client| client.writer.is_some()) - { - match &client.mode { - ClientConnectionMode::App if client.is_full_app_client() => { - has_app_target = true; - } - ClientConnectionMode::TerminalAttach { terminal_id } - | ClientConnectionMode::TerminalObserve { terminal_id } => { - direct_terminal_targets.insert(terminal_id.as_str()); - } - ClientConnectionMode::App => {} - } - } - (has_app_target, direct_terminal_targets) - } - - fn pty_source_visible_to_render_targets( - &self, - pane_id: crate::layout::PaneId, - has_app_target: bool, - direct_terminal_targets: &HashSet<&str>, - ) -> bool { - let terminal_id = self.terminal_id_for_pane(pane_id); - (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) - || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) - } - - fn pty_sources_visible_to_any_render_target( - &self, - sources: &HashSet, - ) -> bool { - let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - if !has_app_target && direct_terminal_targets.is_empty() { - return false; - } - - sources.iter().copied().any(|pane_id| { - self.pty_source_visible_to_render_targets( - pane_id, - has_app_target, - &direct_terminal_targets, - ) - }) - } - - fn terminal_id_for_pane( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<&crate::terminal::TerminalId> { - if let Some(popup) = self - .app - .state - .popup_pane - .as_ref() - .filter(|popup| popup.pane_id == pane_id) - { - return Some(&popup.terminal_id); - } - self.app - .find_pane(pane_id) - .map(|(_, pane)| &pane.attached_terminal_id) - } - - fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { - if self - .app - .state - .popup_pane - .as_ref() - .is_some_and(|popup| popup.pane_id == pane_id) - { - return true; - } - let Some(workspace) = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - else { - return false; - }; - let Some(tab) = workspace.active_tab() else { - return false; - }; - if !tab.panes.contains_key(&pane_id) { - return false; - } - !tab.zoomed || tab.layout.focused() == pane_id - } - - fn render_retained_pty_update_and_stream(&mut self) -> bool { - crate::render_prof::event("retained.attempt"); - let retained_started = crate::render_prof::timer(); - macro_rules! retained_fallback { - ($reason:literal) => {{ - crate::render_prof::event(concat!("retained_fallback.", $reason)); - crate::render_prof::duration_since("retained.total", retained_started); - return false; - }}; - } - macro_rules! retained_success { - ($reason:literal) => {{ - crate::render_prof::event("retained.success"); - crate::render_prof::event(concat!("retained_success.", $reason)); - crate::render_prof::duration_since("retained.total", retained_started); - return true; - }}; - } - - if !self.retained_pty_update_allowed_by_app_state() { - retained_fallback!("unsafe_app_state"); - } - - let render_targets = render_targets(&self.clients, self.foreground_client_id); - let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] = - render_targets.as_slice() - else { - retained_fallback!("multiple_or_no_target"); - }; - if !matches!(mode, ClientConnectionMode::App) { - retained_fallback!("not_app_client"); - } - let Some(client) = self.clients.get(client_id) else { - retained_fallback!("client_missing"); - }; - if client.deferred_render() != DeferredRender::None { - retained_fallback!("render_pending"); - } - if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() { - retained_fallback!("graphics_cache_active"); - } - if client.graphics_surface_reset_pending { - retained_fallback!("graphics_surface_reset"); - } - if self.app.state.kitty_graphics_enabled - && cell_size.is_known() - && crate::kitty_graphics::has_visible_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - *cell_size, - ) - { - retained_fallback!("visible_kitty_graphics"); - } - let Some(mut frame) = client.render_state.last_frame().cloned() else { - retained_fallback!("no_last_frame"); - }; - if frame.width != *cols || frame.height != *rows { - retained_fallback!("frame_size_mismatch"); - } - frame.graphics.clear(); - - let Some(ws_idx) = self.app.state.active else { - retained_fallback!("no_active_workspace"); - }; - let pane_infos = self.app.state.view.pane_infos.clone(); - if pane_infos.is_empty() { - retained_fallback!("no_pane_info"); - } - - let mut touched = false; - for info in pane_infos { - if !rect_fits_frame(info.inner_rect, &frame) { - retained_fallback!("pane_rect_outside_frame"); - } - let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - info.id, - ) else { - retained_fallback!("missing_runtime"); - }; - match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) { - crate::pane::TerminalDirtyPatchOutcome::Clean => { - crate::render_prof::event("retained.pane_clean"); - } - crate::pane::TerminalDirtyPatchOutcome::Fallback => { - retained_fallback!("dirty_patch_fallback"); - } - crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => { - crate::render_prof::event("retained.pane_patch"); - crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64); - if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) { - retained_fallback!("hyperlink_intersection"); - } - if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) { - retained_fallback!("patch_apply_failed"); - } - touched = true; - } - } - } - - let previous_cursor = frame.cursor.clone(); - frame.cursor = crate::server::render_stream::focused_terminal_cursor( - &self.app.state, - &self.app.terminal_runtimes, - ); - let cursor_changed = frame.cursor != previous_cursor; - - if !touched && !cursor_changed { - retained_success!("clean_no_cursor_change"); - } - - let mut broken_clients = Vec::new(); - let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients); - for broken_client in broken_clients { - self.remove_client_and_resize_if_needed(broken_client); - } - if sent { - retained_success!("sent"); - } - retained_fallback!("send_failed"); - } - - fn retained_pty_update_allowed_by_app_state(&self) -> bool { - self.app.state.mode == app::Mode::Terminal - && self.app.state.popup_pane.is_none() - && self.app.state.selection.is_none() - && self.app.state.copy_mode.is_none() - && self.app.state.context_menu.is_none() - && self.app.state.toast.is_none() - && self.app.state.copy_feedback.is_none() - && !self.app.full_redraw_pending - } - - fn send_retained_frame_to_client( - &mut self, - client_id: u64, - frame: FrameData, - broken_clients: &mut Vec, - ) -> bool { - let Some(client) = self.clients.get_mut(&client_id) else { - crate::render_prof::event("retained_send_fallback.client_missing"); - return false; - }; - let Some(writer) = client.writer.as_ref().cloned() else { - crate::render_prof::event("retained_send_fallback.writer_missing"); - return false; - }; - let prepare_started = crate::render_prof::timer(); - let Some(prepared) = client.render_state.prepare_frame(frame) else { - client.clear_deferred_render(); - crate::render_prof::event("retained_send.skip_identical"); - crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); - return true; - }; - crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); - let serialize_started = crate::render_prof::timer(); - let serialized = match Self::frame_server_message(prepared.message()) { - Ok(framed) => { - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - framed - } - Err(protocol::FramingError::Oversized { claimed, max }) => { - warn!( - client_id, - claimed, max, "skipping oversized retained frame for client" - ); - crate::render_prof::event("retained_send_fallback.serialize_oversized"); - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - return false; - } - Err(err) => { - warn!(client_id, err = %err, "failed to serialize retained frame for client"); - broken_clients.push(client_id); - crate::render_prof::event("retained_send_fallback.serialize_error"); - crate::render_prof::duration_since("retained_send.serialize", serialize_started); - return false; - } - }; - crate::render_prof::counter("retained_send.bytes", serialized.len() as u64); - - let send_started = crate::render_prof::timer(); - match writer.render.try_send(serialized) { - Ok(()) => { - client.clear_deferred_render(); - client.render_state.commit_sent_frame(prepared); - crate::render_prof::event("retained_send.sent"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - true - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - crate::render_prof::event("retained_send_fallback.queue_full"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - debug!( - client_id, - "render queue full, deferring latest retained frame" - ); - false - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - debug!(client_id, "client writer channel closed, marking as broken"); - broken_clients.push(client_id); - crate::render_prof::event("retained_send_fallback.writer_disconnected"); - crate::render_prof::duration_since("retained_send.try_send", send_started); - false - } - } - } - - fn render_and_stream(&mut self) { - let full_started = crate::render_prof::timer(); - let render_targets = render_targets(&self.clients, self.foreground_client_id); - - if render_targets.is_empty() { - let (cols, rows) = self.effective_size; - let area = Rect::new(0, 0, cols, rows); - let resize_panes = self.app.state.view.pane_infos.is_empty(); - let render_started = crate::render_prof::timer(); - let _ = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut self.app.state, - &self.app.terminal_runtimes, - area, - resize_panes, - crate::kitty_graphics::HostCellSize::default(), - ); - crate::render_prof::duration_since("full_render.render_virtual", render_started); - self.app.full_redraw_pending = false; - crate::render_prof::duration_since("full_render.total", full_started); - debug!( - cols, - rows, resize_panes, "rendered virtual frame with no attached clients" - ); - return; - } - - let mut broken_clients: Vec = Vec::new(); - let mut deferred_frame = false; - for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { - let area = Rect::new(0, 0, cols, rows); - let is_app_client = matches!(mode, ClientConnectionMode::App); - let mut frame = match mode { - ClientConnectionMode::App => { - let render_started = crate::render_prof::timer(); - let render_cell_size = - if self.app.state.kitty_graphics_enabled && cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; - let preserved_scroll = (!is_foreground).then_some(( - self.app.state.workspace_scroll, - self.app.state.agent_panel_scroll, - self.app.state.tab_scroll, - self.app.state.mobile_switcher_scroll, - )); - let (buffer, cursor) = - crate::server::render_stream::render_virtual_with_runtime_registry( - &mut self.app.state, - &self.app.terminal_runtimes, - area, - is_foreground, - render_cell_size, - ); - if let Some((workspace, agent_panel, tab, mobile_switcher)) = preserved_scroll { - self.app.state.workspace_scroll = workspace; - self.app.state.agent_panel_scroll = agent_panel; - self.app.state.tab_scroll = tab; - self.app.state.mobile_switcher_scroll = mobile_switcher; - } - crate::render_prof::duration_since( - "full_render.render_virtual", - render_started, - ); - let hyperlinks_started = crate::render_prof::timer(); - let hyperlinks = crate::server::render_stream::visible_hyperlinks( - &self.app.state, - &self.app.terminal_runtimes, - ); - crate::render_prof::duration_since( - "full_render.visible_hyperlinks", - hyperlinks_started, - ); - let frame_started = crate::render_prof::timer(); - let frame = FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ); - crate::render_prof::duration_since("full_render.frame_build", frame_started); - frame - } - ClientConnectionMode::TerminalAttach { terminal_id } - | ClientConnectionMode::TerminalObserve { terminal_id } => { - let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some(format!( - "terminal attach ended: terminal {terminal_id} not found" - )), - }, - ); - broken_clients.push(client_id); - continue; - }; - let render_started = crate::render_prof::timer(); - let (buffer, cursor) = - crate::server::render_stream::render_terminal_virtual(runtime, area); - crate::render_prof::duration_since( - "full_render.render_terminal_virtual", - render_started, - ); - let hyperlinks_started = crate::render_prof::timer(); - let hyperlinks = runtime.visible_hyperlinks(area); - crate::render_prof::duration_since( - "full_render.visible_hyperlinks", - hyperlinks_started, - ); - let frame_started = crate::render_prof::timer(); - let frame = FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ); - crate::render_prof::duration_since("full_render.frame_build", frame_started); - frame - } - }; - - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let mut next_graphics_cache = client.graphics_cache.clone(); - let mut reset_graphics = Vec::new(); - let mut encoded = if is_app_client - && self.app.state.kitty_graphics_enabled - && cell_size.is_known() - { - if client.graphics_surface_reset_pending { - if self.app.pane_graphics.slots.is_empty() { - reset_graphics = next_graphics_cache.clear_bytes(); - } else { - next_graphics_cache = crate::kitty_graphics::HostGraphicsCache::default(); - } - } - let graphics_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("full_render.graphics_encode", graphics_started); - encoded - } else if self.app.pane_graphics.slots.is_empty() { - crate::kitty_graphics::EncodedGraphics { - bytes: next_graphics_cache.clear_bytes(), - incomplete: false, - } - } else { - next_graphics_cache.clear_next() - }; - if !reset_graphics.is_empty() { - reset_graphics.extend(encoded.bytes); - encoded.bytes = reset_graphics; - } - frame.graphics = encoded.bytes; - - let Some(writer) = client.writer.as_ref().cloned() else { - crate::render_prof::event("full_render.writer_missing"); - continue; - }; - let mut commit_graphics_cache = true; - if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE { - warn!( - client_id, - graphics_bytes = frame.graphics.len(), - max = MAX_GRAPHICS_FRAME_SIZE, - "dropping oversized graphics payload for client frame" - ); - frame.graphics.clear(); - commit_graphics_cache = false; - encoded.incomplete = false; - } - let has_graphics = !frame.graphics.is_empty(); - let Some(mut prepared) = client.render_state.prepare_frame(frame) else { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - if encoded.incomplete { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.skip_identical"); - continue; - }; - let max = if has_graphics { - MAX_GRAPHICS_FRAME_SIZE - } else { - crate::protocol::MAX_FRAME_SIZE - }; - let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { - Ok(frame) => frame, - Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { - warn!( - client_id, - claimed, max, "dropping graphics from oversized frame for client" - ); - let Some(mut text_only_frame) = prepared.into_frame() else { - crate::render_prof::event("full_render.serialize_error"); - continue; - }; - text_only_frame.graphics.clear(); - let Some(text_only_prepared) = - client.render_state.prepare_frame(text_only_frame) - else { - client.clear_deferred_render(); - crate::render_prof::event("full_render.skip_identical_text_only"); - continue; - }; - let framed = match Self::frame_server_message(text_only_prepared.message()) { - Ok(framed) => framed, - Err(err) => { - warn!(client_id, err = %err, "failed to serialize text-only frame for client"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; - } - }; - prepared = text_only_prepared; - commit_graphics_cache = false; - encoded.incomplete = false; - framed - } - Err(protocol::FramingError::Oversized { claimed, max }) => { - warn!( - client_id, - claimed, max, "skipping oversized frame for client" - ); - crate::render_prof::event("full_render.serialize_oversized"); - continue; - } - Err(err) => { - warn!(client_id, err = %err, "failed to serialize frame"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; - } - }; - match writer.render.try_send(serialized) { - Ok(()) => { - if commit_graphics_cache { - client.graphics_cache = next_graphics_cache; - client.graphics_surface_reset_pending = false; - } - client.render_state.commit_sent_frame(prepared); - if encoded.incomplete { - client.defer_full_render(); - deferred_frame = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("full_render.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred_frame = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id); - } - } - } - - if !broken_clients.is_empty() { - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - } - - let (cols, rows) = self.effective_size; - if !deferred_frame { - self.app.full_redraw_pending = false; - } - crate::render_prof::duration_since("full_render.total", full_started); - debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); - } - /// Handle scheduled tasks for the headless server. /// - /// Similar to `App::handle_scheduled_tasks` but without resize polling - /// (the server doesn't have a terminal to resize). + /// Similar to the former App scheduler but without terminal resize polling. fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; @@ -4763,27 +3204,6 @@ impl HeadlessServer { } } - if self - .app - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.copy_feedback_deadline = None; - self.app.state.copy_feedback = None; - changed = true; - } - - if self - .app - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.app.clear_due_selection_highlight(now); - if self.has_app_client() { self.app.start_git_status_refresh_if_due(now); } @@ -4833,132 +3253,25 @@ impl HeadlessServer { } changed } - - /// Initiates graceful shutdown. - fn initiate_shutdown(&mut self) { - if self.shutting_down { - return; - } - info!("server shutdown initiated"); - self.shutting_down = true; - - // Clear client-local host graphics, then send ServerShutdown to all connected clients. - self.send_all_clients_graphics_cleanup(); - let shutdown_msg = ServerMessage::ServerShutdown { - reason: Some("server is shutting down".to_owned()), - }; - self.send_to_all_clients(shutdown_msg); - - // Give client writer threads a moment to flush the shutdown message. - // A short sleep ensures the message is written to the socket before - // we close the connections. - std::thread::sleep(Duration::from_millis(50)); - - // Signal the main loop to exit. - self.should_quit.store(true, Ordering::Release); - self.app.state.should_quit = true; - } - - /// Completes the shutdown sequence: send ServerShutdown to clients, - /// close client connections, remove socket files, and clean up. - async fn complete_shutdown(&mut self) -> io::Result<()> { - info!("completing server shutdown"); - self.reject_late_client_connections().await; - - // Send ServerShutdown to all remaining clients. - if !self.clients.is_empty() { - self.send_all_clients_graphics_cleanup(); - let shutdown_msg = ServerMessage::ServerShutdown { - reason: Some("server is shutting down".to_owned()), - }; - self.send_to_all_clients(shutdown_msg); - - // Give writer threads a moment to flush before closing. - std::thread::sleep(Duration::from_millis(50)); - } - - // Reject only the requests already queued when shutdown reached cleanup. - self.reject_queued_api_requests_for_shutdown(); - - // Close all client connections. - let staged_files = self - .clients - .drain() - .flat_map(|(_, client)| client.staged_clipboard_files) - .collect::>(); - crate::server::clipboard_image::remove_files(staged_files); - - // Remove socket files. - self.cleanup_sockets()?; - - Ok(()) - } - - /// Removes socket files created by the server. - fn cleanup_sockets(&self) -> io::Result<()> { - if let Err(err) = - remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) - { - if err.kind() != io::ErrorKind::NotFound { - warn!( - path = %self.client_socket_path.display(), - err = %err, - "failed to remove client socket on shutdown" - ); - } - } - Ok(()) - } } -// Pane applications render their own motion responses through PTY output. Only Herdr modes with -// hover selection mutate the current frame directly from a plain mouse-move event. -fn events_are_render_neutral_mouse_motion( - events: &[crate::raw_input::RawInputEvent], - mode: crate::app::Mode, -) -> bool { - !events.is_empty() - && !mode.mouse_motion_changes_view() - && events.iter().all(|event| { - matches!( - event, - crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - .. - }) - ) - }) +fn client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> bool { + matches!( + event, + protocol::ClientPaneInputEvent::Key { + kind: protocol::ClientKeyKind::Release, + .. + } | protocol::ClientPaneInputEvent::Mouse { + kind: protocol::ClientMouseKind::Up(_), + .. + } + ) } -fn events_for_app_routing( - events: Vec, - mut source_is_foreground: bool, - source_is_full_app: bool, -) -> Vec { +fn client_pane_input_has_interaction(events: &[protocol::ClientPaneInputEvent]) -> bool { events - .into_iter() - .filter_map(|event| match event { - crate::raw_input::RawInputEvent::OuterFocusGained - | crate::raw_input::RawInputEvent::OuterFocusLost - if !source_is_full_app => - { - None - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - source_is_foreground = true; - Some(event) - } - crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None, - crate::raw_input::RawInputEvent::Key(_) - | crate::raw_input::RawInputEvent::Text(_) - | crate::raw_input::RawInputEvent::Mouse(_) - | crate::raw_input::RawInputEvent::Paste(_) => { - source_is_foreground = true; - Some(event) - } - _ => Some(event), - }) - .collect() + .iter() + .any(|event| !client_pane_input_releases_press(event)) } impl Drop for HeadlessServer { @@ -5028,6736 +3341,17 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option { } fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option, Option) { - let without_keybindings = diagnostics - .iter() - .filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic)) - .cloned() - .collect::>(); ( config::config_diagnostic_summary(diagnostics), - config::config_diagnostic_summary(&without_keybindings), + config::config_diagnostic_summary_without_keybindings(diagnostics), ) } -fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool { - diagnostic.contains("keybinding") || diagnostic.contains("keys.") -} - // --------------------------------------------------------------------------- // Entry point -// --------------------------------------------------------------------------- - -/// Run the headless server. This is the entry point called from main.rs. -pub fn run_server() -> io::Result<()> { - init_logging(); - crate::platform::raise_server_nofile_limit(); - - let args: Vec = std::env::args().collect(); - if args.get(2).map(String::as_str) == Some("--handoff-import") { - let socket_path = args - .get(3) - .map(PathBuf::from) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; - let token = args - .get(4) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; - return run_handoff_import_server(&socket_path, token); - } - - let loaded_config = config::Config::load(); - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - // Start the JSON API socket server. - let _api_server = match api::start_server_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("api socket: {}", api::socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - let no_session = false; // Server always does session persistence. - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(io::Error::other)?; - - let result = rt.block_on(async { - // Create the App (with AppState, event channels, etc.). - let mut app = app::App::new( - &loaded_config.config, - no_session, - config::config_diagnostic_summary(&loaded_config.diagnostics), - api_rx, - event_hub, - ); - seed_startup_workspace_if_empty(&mut app); - - // The server runs headless — disable local notification side effects. - // Sound and terminal notifications are forwarded to connected clients - // as ServerMessage::Notify instead of emitted by the server process. - // The prefix input-source switch is likewise forwarded to the foreground - // client (ServerMessage::PrefixInputSource), never applied in-process. - app.state.local_sound_playback = false; - app.local_terminal_notifications = false; - app.local_input_source_switch = false; - - // Create the headless server. - let mut server = match HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(_api_server), - should_quit, - ) { - Ok(server) => server, - Err(err) if err.kind() == io::ErrorKind::AddrInUse => { - eprintln!("error: herdr server is already running"); - eprintln!("client socket: {}", client_socket_path().display()); - std::process::exit(1); - } - Err(err) => return Err(err), - }; - - info!( - api_socket = %api::socket_path().display(), - client_socket = %client_socket_path().display(), - "herdr server started" - ); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -fn seed_startup_workspace_if_empty(app: &mut app::App) { - let Some(cwd) = take_startup_cwd() else { - return; - }; - - if !app.state.workspaces.is_empty() { - info!( - cwd = %cwd.display(), - "restored session already has workspaces; ignoring startup cwd" - ); - return; - } - - match app.create_workspace_with_options(cwd.clone(), true) { - Ok(_) => { - info!(cwd = %cwd.display(), "created startup workspace"); - } - Err(err) => { - warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); - app.state.mode = app::Mode::Navigate; - } - } -} - -fn take_startup_cwd() -> Option { - let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; - std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); - (!cwd.is_empty()).then(|| PathBuf::from(cwd)) -} - -#[cfg(unix)] -fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { - let loaded_config = config::Config::load(); - let mut received = crate::server::handoff::receive(socket_path, token)?; - crate::server::handoff::log_import_result(received.manifest.panes.len()); - - let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); - - let mut imports = HashMap::new(); - for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { - let pane_id = pane.pane_id; - imports.insert( - pane_id, - crate::handoff_runtime::ImportedHandoffRuntime { - master_fd: fd, - state: pane, - }, - ); - } - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(io::Error::other)?; - - let result = rt.block_on(async { - let mut app = app::App::new_from_handoff( - &loaded_config.config, - config::config_diagnostic_summary(&loaded_config.diagnostics), - api_rx, - event_hub.clone(), - &received.manifest.snapshot, - &mut imports, - )?; - app.state.local_sound_playback = false; - app.local_terminal_notifications = false; - app.local_input_source_switch = false; - crate::server::handoff::report_restored(&mut received.stream)?; - if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { - return Err(io::Error::other( - "test handoff import failure after restored", - )); - } - wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; - - let api_server = api::start_server_with_stop_control( - api_tx.clone(), - event_hub.clone(), - should_quit.clone(), - )?; - let mut server = HeadlessServer::new( - app, - &loaded_config.diagnostics, - Some(api_tx.clone()), - Some(api_server), - should_quit, - )?; - // Carried across before any client attaches, so the first title sent is - // the override rather than the configured one it replaced. - server.api_window_title = received.manifest.api_window_title.take(); - crate::server::handoff::report_ready(&mut received.stream)?; - crate::server::handoff::wait_committed(&mut received.stream)?; - server.app.assume_handoff_ownership(); - server.app.unpause_handoff_readers(); - server.pending_handoff_repaint_nudge = true; - if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { - warn!(err = %err, "failed to report handoff ownership; continuing as owner"); - } - info!("handoff import server started"); - print_ready_message(&api::socket_path(), &client_socket_path()); - server.app.run_plugin_startup_hooks(); - server.run().await - }); - - rt.shutdown_timeout(Duration::from_millis(100)); - crate::logging::shutdown("server"); - result -} - -#[cfg(unix)] -fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { - let deadline = Instant::now() + timeout; - let api_socket = api::socket_path(); - let client_socket = client_socket_path(); - while Instant::now() < deadline { - let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); - let client_open = - client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); - if !api_open && !client_open { - return Ok(()); - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(io::Error::new( - io::ErrorKind::TimedOut, - "old server sockets did not close before handoff import bind", - )) -} - -#[cfg(not(unix))] -fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { - Err(io::Error::other("live handoff is only supported on Unix")) -} - -fn print_ready_message(api_socket: &Path, client_socket: &Path) { - eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); - eprintln!("api socket: {}", api_socket.display()); - eprintln!("client socket: {}", client_socket.display()); - eprintln!( - "logs: {}", - crate::session::data_dir() - .join("herdr-server.log") - .display() - ); - eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); -} - -/// Initialize logging for the server process. -fn init_logging() { - crate::logging::init_file_logging("herdr-server.log"); -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - - use crate::app::AppState; - use crate::protocol::{CellData, CursorState}; - use unicode_width::UnicodeWidthStr; - - #[path = "pane_graphics.rs"] - mod pane_graphics_tests; - - #[test] - fn retained_render_plan_covers_each_render_path() { - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: true, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Full - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: true, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::Graphics - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Visible, - }), - RetainedRenderPlan::Pty - ); - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty: PtyRenderState::Hidden, - }), - RetainedRenderPlan::HiddenPty - ); - } - - fn test_headless_server() -> HeadlessServer { - test_headless_server_with_event_hub(api::EventHub::default()) - } - - fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { - let config = crate::config::Config::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new(&config, true, None, api_rx, event_hub); - app.state.local_sound_playback = false; - app.local_terminal_notifications = false; - app.local_input_source_switch = false; - - let dir = std::env::temp_dir().join(format!( - "hh-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - let _ = fs::create_dir_all(&dir); - let socket_path = dir.join("client.sock"); - let _ = fs::remove_file(&socket_path); - let listener = bind_local_listener(&socket_path).expect("bind test listener"); - let client_socket_identity = - socket_file_identity(&socket_path).expect("test listener socket identity"); - #[cfg(unix)] - listener - .set_nonblocking(ListenerNonblockingMode::Accept) - .expect("set listener nonblocking"); - let (server_event_tx, server_event_rx) = mpsc::channel(64); - let should_quit = Arc::new(AtomicBool::new(false)); - #[cfg(windows)] - spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); - let server_keybindings = app_keybindings(&app); - let headless_size = app.state.headless_size; - - HeadlessServer { - app, - #[cfg(unix)] - api_tx: None, - api_server: None, - #[cfg(unix)] - client_listener: listener, - client_socket_path: socket_path, - client_socket_identity, - clients: HashMap::new(), - #[cfg(unix)] - next_client_id: 1, - foreground_client_id: None, - sent_window_title: None, - api_window_title: None, - server_keybindings, - server_config_diagnostic: None, - server_config_diagnostic_without_keybindings: None, - terminal_attach_owners: HashMap::new(), - pending_alt_screen_reads: Vec::new(), - deferred_alt_screen_reads: Vec::new(), - next_activity_stamp: 1, - headless_size, - effective_size: headless_size, - shutting_down: false, - handoff_in_progress: false, - #[cfg(unix)] - pending_handoff_repaint_nudge: false, - should_quit, - server_event_rx, - server_event_tx, - } - } - - fn shutdown_test_runtimes(server: &mut HeadlessServer) { - for (_, runtime) in server.app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn read_server_message(bytes: Vec) -> ServerMessage { - let mut cursor = std::io::Cursor::new(bytes); - protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") - } - - fn read_server_frame(bytes: Vec) -> FrameData { - match protocol::read_message(&mut std::io::Cursor::new(bytes), MAX_GRAPHICS_FRAME_SIZE) - .expect("decode server frame") - { - ServerMessage::Frame(frame) => frame, - other => panic!("expected frame, got {other:?}"), - } - } - - fn frame_text(frame: &FrameData) -> String { - frame - .cells - .chunks(usize::from(frame.width)) - .map(|row| { - row.iter() - .map(|cell| cell.symbol.as_str()) - .collect::() - }) - .collect::>() - .join("\n") - } - - fn read_server_shutdown_reason(bytes: Vec) -> Option { - match read_server_message(bytes) { - ServerMessage::ServerShutdown { reason } => reason, - other => panic!("expected shutdown, got {other:?}"), - } - } - - #[test] - fn default_headless_size_is_effective_without_clients() { - let server = test_headless_server(); - - assert_eq!( - server.headless_size, - ( - crate::config::DEFAULT_HEADLESS_COLS, - crate::config::DEFAULT_HEADLESS_ROWS - ) - ); - assert_eq!(server.effective_size, server.headless_size); - } - - #[tokio::test] - async fn headless_api_reads_latest_title_without_spinner_event_flooding() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(crate::detect::Agent::Claude); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - server.app.render_dirty.request_terminal_title(pane_id); - - let first = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); - assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - Rect::new(0, 0, 100, 30), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let rendered = buffer - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(rendered.contains("task"), "rendered frame: {rendered:?}"); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); - server.app.render_dirty.request_terminal_title(pane_id); - let second = headless_pane_list(&mut server).pop().unwrap(); - assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); - assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); - assert_eq!(pane_updated_events(&event_hub), 1); - } - - fn headless_pane_list(server: &mut HeadlessServer) -> Vec { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "list-titles".into(), - method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - let response: api::schema::SuccessResponse = - serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); - let api::schema::ResponseResult::PaneList { panes } = response.result else { - panic!("expected pane list"); - }; - panes - } - - fn pane_updated_events(event_hub: &api::EventHub) -> usize { - event_hub - .events_after(0) - .iter() - .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) - .count() - } - - #[test] - fn server_stop_interrupts_server_event_backlog() { - let mut server = test_headless_server(); - for client_id in 1..=64 { - server - .server_event_tx - .try_send(ServerEvent::ClientDisconnected { client_id }) - .unwrap(); - } - - server.should_quit.store(true, Ordering::Release); - - assert!(!server.drain_server_events()); - assert!(server.server_event_rx.try_recv().is_ok()); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { - let mut server = test_headless_server(); - for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: format!("4.0.{i}"), - install_command: "herdr install".into(), - }) - .unwrap(); - } - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "headless_stop_after_events".into(), - method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "ok"); - let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); - assert_eq!( - server.app.state.update_available.as_deref(), - Some(expected_version.as_str()) - ); - assert!(server.app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn headless_deferred_workspace_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - - server.app.state.request_new_workspace = true; - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_workspace); - assert_eq!( - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect::>(), - vec![ - api::schema::EventKind::WorkspaceCreated, - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn headless_deferred_named_tab_create_uses_runtime_events() { - let event_hub = api::EventHub::default(); - let mut server = test_headless_server_with_event_hub(event_hub.clone()); - server - .app - .create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - let after_setup = event_hub.current_sequence(); - - server.app.state.request_new_tab = true; - server.app.state.requested_new_tab_name = Some("ops".into()); - - assert!(server.handle_deferred_requests_headless()); - assert!(!server.app.state.request_new_tab); - assert_eq!(server.app.state.requested_new_tab_name, None); - let events = event_hub.events_after(after_setup); - assert_eq!( - events - .iter() - .map(|(_, event)| event.event) - .collect::>(), - vec![ - api::schema::EventKind::TabCreated, - api::schema::EventKind::PaneCreated, - api::schema::EventKind::LayoutUpdated, - ] - ); - let tab_created = events - .iter() - .find_map(|(_, event)| match &event.data { - api::schema::EventData::TabCreated { tab } => Some(tab), - _ => None, - }) - .expect("tab created event"); - assert_eq!(tab_created.label, "ops"); - shutdown_test_runtimes(&mut server); - } - - fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver>) { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - drain_window_titles(&control_rx); - (server, control_rx) - } - - /// The test client writer drains its queue on a background thread, so - /// reading a pushed message needs a timeout rather than `try_recv`. - fn next_window_title( - control_rx: &std::sync::mpsc::Receiver>, - ) -> Option> { - let deadline = Instant::now() + Duration::from_secs(5); - while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { - let Ok(bytes) = control_rx.recv_timeout(remaining) else { - return None; - }; - if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { - return Some(title); - } - } - None - } - - fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver>) { - while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} - } - - fn no_window_title(control_rx: &std::sync::mpsc::Receiver>) -> bool { - while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { - if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { - return false; - } - } - true - } - - #[test] - fn window_title_waits_for_a_foreground_client_to_exist() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; - server.app.state.active = Some(0); - server.app.configure_window_title("{workspace}"); - - // The server renders before the first client attaches. Nothing was - // delivered, so nothing may be recorded as delivered either. - server.sync_window_title(); - assert_eq!(server.sent_window_title, None); - - let (client_tx, control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(1); - server.sync_window_title(); - - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // ClientConnected assigns the foreground client directly rather than - // going through promote_client_to_foreground, so the cache must notice - // the new client on its own. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn configured_window_title_reaches_the_foreground_client_once_per_change() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}/{tab}"); - - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/1".to_string())) - ); - - // An unchanged title must not re-emit an OSC on every render. - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd/build".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{terminal_title}"); - server.app.state.ensure_test_terminals(); - let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = server.app.state.workspaces[0] - .terminal_id(pane_id) - .expect("terminal") - .clone(); - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); - runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); - server - .app - .terminal_runtimes - .insert(terminal_id.clone(), runtime); - - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert_eq!( - next_window_title(&control_rx), - Some(Some("building".to_string())) - ); - - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); - assert_eq!( - server.sync_terminal_title_sources(&HashSet::from([pane_id])), - (false, true) - ); - assert!(no_window_title(&control_rx)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { - let (mut server, _control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - // A detached client keeps its entry but loses its writer, so nothing - // reaches a terminal even though the targeted send reports success. - if let Some(client) = server.clients.get_mut(&1) { - client.writer = None; - } - server.sync_window_title(); - assert!(server.sent_window_title.is_none()); - - // Attaching again has to deliver the title rather than skip it as sent. - let (client_tx, control_rx, _render_rx) = test_client_writer(); - if let Some(client) = server.clients.get_mut(&1) { - client.writer = Some(client_tx); - } - server.sync_window_title(); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herd".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn empty_window_title_config_leaves_the_outer_title_alone() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.sync_window_title(); - - assert!(no_window_title(&control_rx)); - shutdown_test_runtimes(&mut server); - } - - #[test] - fn api_window_title_wins_until_it_is_cleared() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.app.state.workspaces[0].custom_name = Some("ops".into()); - server.sync_window_title(); - assert!(no_window_title(&control_rx)); - - // Clearing hands the title back to ui.window_title, not to "herdr". - server.handle_client_window_title_api("clear".into(), None); - assert_eq!( - next_window_title(&control_rx), - Some(Some("ops".to_string())) - ); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { - let (mut server, control_rx) = window_title_test_server(); - server.app.configure_window_title(""); - - server.handle_client_window_title_api("set".into(), Some("herdr api".into())); - assert_eq!( - next_window_title(&control_rx), - Some(Some("herdr api".to_string())) - ); - - server.handle_client_window_title_api("clear".into(), None); - assert_eq!(next_window_title(&control_rx), Some(None)); - - shutdown_test_runtimes(&mut server); - } - - #[test] - fn a_newly_promoted_client_gets_the_window_title_again() { - let (mut server, first_control_rx) = window_title_test_server(); - server.app.configure_window_title("{workspace}"); - server.sync_window_title(); - assert_eq!( - next_window_title(&first_control_rx), - Some(Some("herd".to_string())) - ); - - // A second terminal starts on whatever its shell or ssh left behind. - let (client_tx, second_control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.promote_client_to_foreground(2); - server.sync_window_title(); - - assert_eq!( - next_window_title(&second_control_rx), - Some(Some("herd".to_string())) - ); - shutdown_test_runtimes(&mut server); - } - - fn test_client_writer() -> ( - ClientWriter, - std::sync::mpsc::Receiver>, - std::sync::mpsc::Receiver>, - ) { - let (control_tx, control_rx) = std::sync::mpsc::channel(); - let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); - ( - ClientWriter::test_channel(control_tx, render_tx), - control_rx, - render_rx, - ) - } - - fn retained_test_server( - initial_screen: &[u8], - ) -> ( - HeadlessServer, - std::sync::mpsc::Receiver>, - crate::layout::PaneId, - ) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().expect("focused pane"); - workspace.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - (server, client_rx, pane_id) - } - - fn hidden_pty_visibility_test_server( - client_sizes: &[(u16, u16)], - ) -> (HeadlessServer, crate::layout::PaneId) { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - for (index, &terminal_size) in client_sizes.iter().enumerate() { - let client_id = index as u64 + 1; - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - client_id, - ClientConnection::new( - terminal_size, - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - client_id, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - } - - (server, background_pane) - } - - fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { - assert_eq!( - (actual.width, actual.height), - (expected.width, expected.height) - ); - assert_eq!(actual.cursor, expected.cursor, "cursor mismatch"); - assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch"); - assert_eq!(actual.graphics, expected.graphics, "graphics mismatch"); - assert_eq!( - actual.cells.len(), - expected.cells.len(), - "cell length mismatch" - ); - for (idx, (actual_cell, expected_cell)) in - actual.cells.iter().zip(expected.cells.iter()).enumerate() - { - if cells_equivalent_for_frame_compare( - &actual.cells, - &expected.cells, - usize::from(actual.width), - idx, - actual_cell, - expected_cell, - ) { - continue; - } - assert_eq!( - actual_cell, - expected_cell, - "cell mismatch at index {idx} (x={}, y={})", - idx % usize::from(actual.width), - idx / usize::from(actual.width), - ); - } - } - - fn cells_equivalent_for_frame_compare( - actual_cells: &[CellData], - expected_cells: &[CellData], - width: usize, - idx: usize, - actual: &CellData, - expected: &CellData, - ) -> bool { - if actual == expected { - return true; - } - if !cell_style_without_symbol_eq(actual, expected) { - return false; - } - if !matches!( - (actual.symbol.as_str(), expected.symbol.as_str()), - ("", " ") | (" ", "") - ) { - return false; - } - covered_by_previous_wide_cell(actual_cells, width, idx) - || covered_by_previous_wide_cell(expected_cells, width, idx) - } - - fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool { - a.fg == b.fg - && a.bg == b.bg - && a.modifier == b.modifier - && a.skip == b.skip - && a.hyperlink == b.hyperlink - } - - fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool { - if idx == 0 || idx.is_multiple_of(width) { - return false; - } - frame_cell_display_width(&cells[idx - 1]) > 1 - } - - fn frame_cell_display_width(cell: &CellData) -> usize { - if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { - return 2; - } - cell.symbol.width() - } - - fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { - let mut chars = symbol.chars(); - let Some(base) = chars.next() else { - return false; - }; - let Some(mark) = chars.next() else { - return false; - }; - chars.next().is_none() - && ('\u{ff66}'..='\u{ff9d}').contains(&base) - && matches!(mark, '\u{ff9e}' | '\u{ff9f}') - } - - #[test] - fn direct_graphics_requires_one_negotiated_app_client() { - let mut server = test_headless_server(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer: writer_a, - })); - assert!(server.clients[&1].direct_graphics); - assert!(server.clients[&1].pixel_mouse); - assert!(server.direct_graphics_available()); - - let (writer_b, _control_b, _render_b) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert!(!server.direct_graphics_available()); - } - - #[test] - fn foreground_client_applies_client_keybindings() { - let mut server = test_headless_server(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_tab = "prefix+t" -"#, - ) - .unwrap(); - let local_keybindings = local_config.live_keybinds().unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('a') - ); - assert!(server - .app - .state - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+t")); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('b') - ); - assert!(server - .app - .state - .keybinds - .new_tab - .bindings - .iter() - .any(|binding| binding.label == "prefix+c")); - } - - #[test] - fn local_keybinding_client_hides_server_keybinding_warnings() { - let mut server = test_headless_server(); - let diagnostics = vec![ - "unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(), - "theme warning".to_owned(), - ]; - let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics); - server.server_config_diagnostic = full.clone(); - server.server_config_diagnostic_without_keybindings = without_keybindings.clone(); - server.app.state.config_diagnostic = full; - let local_keybindings = crate::config::Config::default().live_keybinds().unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - assert_eq!(server.app.state.config_diagnostic, without_keybindings); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.config_diagnostic, - server.server_config_diagnostic - ); - } - - #[test] - fn local_keybinding_client_keeps_local_keybindings_after_settings_save() { - let path = std::env::temp_dir().join(format!( - "herdr-headless-settings-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::write(&path, "onboarding = false\n").unwrap(); - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut server = test_headless_server(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_workspace = "prefix+n" -next_tab = "" -"#, - ) - .unwrap(); - let local_keybindings = local_config.live_keybinds().unwrap(); - let (writer, _control, _render) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_keybindings)), - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - server.app.state.mode = crate::app::Mode::Settings; - server.app.state.settings.section = crate::app::state::SettingsSection::Toast; - server.app.state.settings.list.selected = 1; - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\r".to_vec(), - })); - - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('a') - ); - assert!(server - .app - .state - .keybinds - .new_workspace - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - assert!(server.app.state.toast.is_none()); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"herdr\"")); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_file(path); - } - - #[test] - fn invalid_server_keybindings_apply_valid_subset_after_settings_save_without_caching_local_keybindings( - ) { - let path = std::env::temp_dir().join(format!( - "herdr-headless-invalid-settings-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::write( - &path, - "onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n", - ) - .unwrap(); - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut server = test_headless_server(); - let previous_server_config: crate::config::Config = - toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap(); - server.server_keybindings = previous_server_config.live_keybinds().unwrap(); - let local_config: crate::config::Config = toml::from_str( - r#" -[keys] -prefix = "ctrl+a" -new_workspace = "prefix+n" -next_tab = "" -"#, - ) - .unwrap(); - let (writer_a, _control_a, _render_a) = test_client_writer(); - let (writer_b, _control_b, _render_b) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 1, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: Some(Box::new(local_config.live_keybinds().unwrap())), - direct_attach_requested: false, - direct_graphics: false, - writer: writer_a, - })); - server.app.state.mode = crate::app::Mode::Settings; - server.app.state.settings.section = crate::app::state::SettingsSection::Toast; - server.app.state.settings.list.selected = 1; - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\r".to_vec(), - })); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer: writer_b, - })); - assert_eq!( - server.app.state.prefix_code, - crossterm::event::KeyCode::Char('b') - ); - assert!(!server - .app - .state - .keybinds - .new_workspace - .bindings - .iter() - .any(|binding| binding.label == "prefix+n")); - assert!(server.app.state.keybinds.new_workspace.bindings.is_empty()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_file(path); - } - - #[test] - fn terminal_attach_rejects_missing_terminal_and_removes_client() { - let mut server = test_headless_server(); - let (writer, control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!(server.clients.contains_key(&7)); - - assert!( - !server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: "term_missing".to_owned(), - takeover: false, - }) - ); - assert!(!server.clients.contains_key(&7)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!( - reason, - Some("terminal attach failed: terminal term_missing not found".to_owned()) - ); - } - - fn with_terminal_session_test_server( - test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); - let terminal_id_string = terminal_id.to_string(); - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.terminal_runtimes.insert( - terminal_id.clone(), - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - - test(&mut server, terminal_id, terminal_id_string, public_pane_id); - - drop(server); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { - let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); - } - - fn connect_pending_terminal_client_with_control_rx( - server: &mut HeadlessServer, - client_id: u64, - ) -> std::sync::mpsc::Receiver> { - let (writer, control_rx, _render_rx) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - control_rx - } - - #[test] - fn explicit_agent_history_read_requires_idle_on_alternate_screen() { - with_terminal_session_test_server( - |server, terminal_id, _terminal_id_string, public_pane_id| { - let terminal = server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("terminal"); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = crate::detect::AgentState::Working; - server.app.terminal_runtimes.insert( - terminal_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 80, - 24, - b"\x1b[?1049hworking", - ), - ); - let request = api::schema::Request { - id: "read".into(), - method: api::schema::Method::AgentRead(api::schema::AgentReadParams { - target: public_pane_id.clone(), - source: api::schema::ReadSource::Recent, - lines: Some(200), - format: api::schema::ReadFormat::Text, - strip_ansi: true, - }), - }; - - assert_eq!( - server.agent_read_not_idle_error(&request), - Some(api::schema::ErrorBody { - code: "agent_not_idle".into(), - message: format!( - "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" - ), - }) - ); - - let mut default_request = request.clone(); - let api::schema::Method::AgentRead(params) = &mut default_request.method else { - unreachable!(); - }; - params.lines = None; - assert_eq!(server.agent_read_not_idle_error(&default_request), None); - - let mut visible_request = request; - let api::schema::Method::AgentRead(params) = &mut visible_request.method else { - unreachable!(); - }; - params.source = api::schema::ReadSource::Visible; - assert_eq!(server.agent_read_not_idle_error(&visible_request), None); - }, - ); - } - - #[test] - fn terminal_observe_allows_multiple_clients_without_attach_ownership() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - let initial_size = server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(); - - for client_id in [7, 8] { - connect_pending_terminal_client(server, client_id); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id, - target: terminal_id_string.clone(), - }) - ); - } - - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - initial_size - ); - assert_eq!( - terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), - 2 - ); - }); - } - - #[test] - fn terminal_observe_resolves_public_pane_id() { - with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: public_pane_id, - }) - ); - - assert!(matches!( - server.clients.get(&7).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) - if observed == &terminal_id.to_string() - )); - }); - } - - #[test] - fn terminal_control_resolves_public_pane_id_and_takes_ownership() { - with_terminal_session_test_server( - |server, terminal_id, terminal_id_string, public_pane_id| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: public_pane_id, - takeover: false, - }) - ); - - assert!(matches!( - server.clients.get(&7).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) - if attached == &terminal_id_string - )); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }, - ); - } - - #[test] - fn terminal_control_rejects_attach_during_alt_screen_read() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - let (respond_to, _response_rx) = std::sync::mpsc::channel(); - server.pending_alt_screen_reads.push( - crate::server::alt_screen_read::PendingAltScreenRead::start( - terminal_id, - "read".into(), - respond_to, - "fallback".into(), - api::schema::PaneReadResult { - pane_id: "w1:p1".into(), - workspace_id: "w1".into(), - tab_id: "w1:t1".into(), - source: api::schema::ReadSource::Recent, - format: api::schema::ReadFormat::Text, - text: String::new(), - revision: 0, - truncated: false, - }, - 120, - false, - crate::terminal::ScreenSnapshot { - cols: 80, - rows: Vec::new(), - }, - 0, - Instant::now(), - ), - ); - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!( - reason, - Some(format!( - "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" - )) - ); - }); - } - - #[test] - fn terminal_control_rejects_second_controller_without_takeover() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - !server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.clients.contains_key(&7)); - assert!(!server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - }); - } - - #[test] - fn terminal_control_takeover_replaces_existing_controller() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 8, - target: terminal_id_string.clone(), - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.clients.contains_key(&8)); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&8) - ); - }); - } - - #[test] - fn terminal_observe_can_coexist_with_terminal_control() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - connect_pending_terminal_client(server, 8); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 8, - target: terminal_id_string.clone(), - }) - ); - - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(matches!( - server.clients.get(&8).map(|client| &client.mode), - Some(ClientConnectionMode::TerminalObserve { terminal_id }) - if terminal_id == &terminal_id_string - )); - assert_eq!( - terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), - 2 - ); - }); - } - - #[test] - fn terminal_control_detach_sends_shutdown_before_removal() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { - let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientControlTerminal { - client_id: 7, - target: terminal_id_string.clone(), - takeover: false, - }) - ); - - assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server - .terminal_attach_owners - .contains_key(&terminal_id_string)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some("detached".to_owned())); - }); - } - - #[test] - fn terminal_observe_rejects_later_attach_upgrade() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - assert!( - !server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string, - takeover: true, - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - #[test] - fn terminal_attach_rejects_later_observe_and_clears_ownership() { - with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { - connect_pending_terminal_client(server, 7); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id_string.clone(), - takeover: false, - }) - ); - assert_eq!( - server.terminal_attach_owners.get(&terminal_id_string), - Some(&7) - ); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - - assert!( - !server.handle_server_event(ServerEvent::ClientObserveTerminal { - client_id: 7, - target: terminal_id_string.clone(), - }) - ); - - assert!(!server.clients.contains_key(&7)); - assert!(server.terminal_attach_owners.is_empty()); - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - }); - } - - fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let future = Instant::now() + Duration::from_secs(60); - server.app.last_git_remote_status_refresh = future; - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - - assert!(server.has_app_client()); - assert!(server - .app - .git_refresh_deadline() - .is_some_and(|deadline| deadline <= Instant::now())); - } - - #[test] - fn terminal_ansi_app_client_enables_headless_git_refresh() { - app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi); - } - - #[test] - fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - - assert!(!server.has_app_client()); - assert_eq!( - server.app.next_headless_loop_deadline_with_git_refresh( - Instant::now(), - false, - server.has_app_client() - ), - None - ); - } - - #[test] - fn writerless_app_client_does_not_enable_headless_git_refresh() { - let mut server = test_headless_server(); - server - .app - .state - .workspaces - .push(crate::workspace::Workspace::test_new("test")); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: false, - writer, - })); - assert!(server.has_app_client()); - - server.clients.get_mut(&7).expect("client").writer = None; - - assert!(!server.has_app_client()); - assert_eq!( - server.app.next_headless_loop_deadline_with_git_refresh( - Instant::now(), - false, - server.has_app_client() - ), - None - ); - } - - #[test] - fn semantic_app_client_marks_git_refresh_due_on_first_attach() { - app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame); - } - - #[test] - fn unchanged_git_refresh_does_not_request_headless_render() { - let mut server = test_headless_server(); - server.app.git_refresh_in_flight = true; - let mut workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - workspace.cached_auto_label = "cached".into(); - workspace.cached_git_status_key = cwd.clone(); - workspace.cached_git_branch = None; - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "cached".into(), - branch: None, - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(!changed); - assert!(!server.app.git_refresh_in_flight); - } - - #[test] - fn changed_git_refresh_requests_headless_render() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("one"); - let workspace_id = workspace.id.clone(); - let cwd = workspace.identity_cwd.clone(); - server.app.state.workspaces.push(workspace); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { - results: vec![crate::workspace::WorkspaceGitStatus { - workspace_id, - resolved_identity_cwd: cwd.clone(), - status_cache_key: cwd, - demand: crate::workspace::GitStatusRefreshDemand::ALL, - auto_label: "one".into(), - branch: Some("changed".into()), - ahead_behind: None, - space: None, - }], - cache_updates: Vec::new(), - }); - - assert!(changed); - } - - #[test] - fn terminal_attach_client_exits_when_attached_pane_dies() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("attached"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .to_string(); - let (writer, control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 7, - terminal_id: terminal_id.clone(), - takeover: false, - }) - ); - assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); - - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); - - assert!(!server.clients.contains_key(&7)); - assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); - let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); - assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); - } - - #[test] - fn terminal_attach_scroll_moves_attached_runtime_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Up, - 3, - None, - None, - 0, - ) - .expect("scroll up"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 3); - - apply_terminal_attach_scroll( - &runtime, - AttachScrollSource::Wheel, - AttachScrollDirection::Down, - 2, - None, - None, - 0, - ) - .expect("scroll down"); - let metrics = runtime.scroll_metrics().expect("scroll metrics"); - assert_eq!(metrics.offset_from_bottom, 1); - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn terminal_attach_input_resets_scrolled_viewport() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - - runtime.scroll_up(4); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - - apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded input"), - Bytes::from("x") - ); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn with_terminal_attach_runtime( - initial_bytes: &[u8], - initial_scroll: usize, - test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver), - ) { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut bytes = initial_bytes.to_vec(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 20, 5, 4096, &bytes, 4, - ); - if initial_scroll > 0 { - runtime.scroll_up(initial_scroll); - } - - test(&runtime, &mut input_rx); - - drop(runtime); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { - apply_terminal_attach_scroll( - runtime, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec(), - }, - AttachScrollDirection::Up, - 4, - None, - None, - 0, - ) - .expect("page key"); - } - - #[test] - fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"line one\nline two") - ); - }); - } - - #[test] - fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { - with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) - .expect("attach paste"); - - assert_eq!( - input_rx.try_recv().expect("forwarded paste"), - Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_plain_terminal() { - with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_when_application_cursor() { - with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { - with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 4 - ); - assert!(input_rx.try_recv().is_err()); - }); - } - - #[test] - fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { - with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { - apply_terminal_attach_page_up(runtime); - - assert_eq!( - runtime - .scroll_metrics() - .expect("scroll metrics") - .offset_from_bottom, - 0 - ); - assert_eq!( - input_rx.try_recv().expect("forwarded page key"), - Bytes::from_static(b"\x1b[5~") - ); - }); - } - - #[test] - fn headless_scheduled_tasks_expire_agent_metadata() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("metadata"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { - pane_id, - source: "custom:pi".into(), - agent_label: "pi".into(), - state: crate::detect::AgentState::Working, - message: None, - seq: None, - session_ref: None, - }) - ); - assert!( - server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { - pane_id, - source: "user:pi-display".into(), - agent_label: Some("pi".into()), - applies_to_source: Some("custom:pi".into()), - title: Some("short lived".into()), - display_agent: None, - state_labels: HashMap::new(), - clear_title: false, - clear_display_agent: false, - clear_state_labels: false, - seq: None, - // Expiry is advanced with the captured deadline below; keep the - // pre-expiry assertion independent of wall-clock scheduling. - ttl: Some(Duration::from_secs(60)), - }) - ); - - let deadline = server - .app - .agent_metadata_deadline - .expect("metadata deadline"); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .expect("pane") - .attached_terminal_id - .clone(); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title() - .as_deref(), - Some("short lived") - ); - - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); - - assert_eq!(server.app.agent_metadata_deadline, None); - assert_eq!( - server - .app - .state - .terminals - .get(&terminal_id) - .expect("terminal") - .effective_title(), - None - ); - assert!(server - .app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { - event.event == crate::api::schema::EventKind::PaneAgentStatusChanged - && matches!( - &event.data, - crate::api::schema::EventData::PaneAgentStatusChanged { - title, - .. - } if title.is_none() - ) - })); - } - - #[test] - fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { - let mut server = test_headless_server(); - let now = Instant::now(); - server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); - - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert_eq!(server.app.next_agent_manifest_update_check, None); - } - - #[tokio::test] - async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.view.pane_infos = workspace.tabs[0] - .layout - .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - server.app.state.host_terminal_theme, - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.effective_size = (100, 30); - server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(server.app.pending_agent_resume_deadline.is_none()); - } - - #[cfg(unix)] - #[tokio::test] - async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - - server.render_and_stream(); - assert_ne!(server.app.state.view.terminal_area, Rect::default()); - - let now = Instant::now(); - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - let deadline = server - .app - .pending_agent_resume_deadline - .expect("clientless resume should wait briefly for a host theme"); - - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_none()); - shutdown_test_runtimes(&mut server); - } - - #[tokio::test] - async fn headless_pre_input_resize_does_not_start_pending_agent_resume() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("restored"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); - server.app.state.view.pane_infos = workspace.tabs[0] - .layout - .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.ensure_test_terminals(); - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - server.app.state.host_terminal_theme, - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.effective_size = (100, 30); - server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - server - .app - .state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - server.resize_shared_runtime_to_effective_size_before_input(); - - assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(server - .app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(server.app.pending_agent_resume_deadline.is_none()); - } - - #[test] - fn virtual_render_produces_nonempty_buffer() { - let mut state = AppState::test_new(); - let area = Rect::new(0, 0, 80, 24); - let (buffer, _cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - assert_eq!(buffer.area.width, 80); - assert_eq!(buffer.area.height, 24); - } - - #[test] - fn virtual_render_without_frame_cursor_keeps_cursor_hidden() { - let mut state = AppState::test_new(); - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert_eq!(cursor, None); - } - - #[tokio::test] - async fn virtual_render_preserves_explicit_frame_cursor_position() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: true, - shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), - }) - ); - } - - #[tokio::test] - async fn virtual_render_preserves_hidden_focused_pane_cursor_position() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: false, - shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0), - }) - ); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); - assert!(runtime.synchronized_output_active()); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, false); - - assert_eq!( - cursor, None, - "child cursor positions are unstable while synchronized output is active" - ); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output_resize() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let initial_area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, initial_area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H"); - assert!(runtime.synchronized_output_active()); - - let resized_area = Rect::new(0, 0, 100, 30); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, resized_area, true); - - assert_eq!( - cursor, None, - "pre-resize synchronized output should suppress the cursor even if resize clears the mode" - ); - } - - #[tokio::test] - async fn virtual_render_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x + 4, - y: pane.inner_rect.y, - visible: true, - shape: state.cjk_ime_cursor_shape, - }) - ); - } - - #[tokio::test] - async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime( - ) { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.scroll_up(6); - assert!(crate::ui::pane_is_scrolled_back(runtime)); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - // Feed only ?25l with no prior cursor movement — exercises the fallback - // path for TUIs whose viewport has no cursor position. - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - let pane = state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("focused pane info"); - - assert_eq!( - cursor, - Some(CursorState { - x: pane.inner_rect.x, - y: pane.inner_rect.y, - visible: true, - shape: state.cjk_ime_cursor_shape, - }), - "fallback should anchor at pane top-left with the configured shape", - ); - } - - #[tokio::test] - async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - // Filter only Claude, but the test pane has no detected agent, so the - // reveal must not apply. - state.cjk_ime_agent_filter_configured = true; - state.cjk_ime_agents = vec![crate::detect::Agent::Claude]; - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_skips_reveal_when_agent_filter_has_no_valid_entries() { - let mut state = AppState::test_new(); - state.reveal_hidden_cursor_for_cjk_ime = true; - state.cjk_ime_agent_filter_configured = true; - state.cjk_ime_agents = Vec::new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "agent filter with no valid entries should suppress reveal; got {cursor:?}", - ); - } - - #[tokio::test] - async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Navigate; - - let area = Rect::new(0, 0, 44, 24); - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert_eq!(cursor, None); - } - - #[tokio::test] - async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() { - let mut state = AppState::test_new(); - let mut ws = crate::workspace::Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let mut bytes = Vec::new(); - for line in 0..80 { - bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); - } - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); - ws.insert_test_runtime(pane_id, runtime); - - state.workspaces = vec![ws]; - state.active = Some(0); - state.selected = 0; - state.mode = crate::app::Mode::Terminal; - - let area = Rect::new(0, 0, 80, 24); - let _ = crate::server::render_stream::render_virtual(&mut state, area, true); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let runtime = state - .runtime_for_pane(&terminal_runtimes, pane_id) - .expect("pane runtime after initial render"); - runtime.scroll_up(6); - assert!(crate::ui::pane_is_scrolled_back(runtime)); - - let (_buffer, cursor) = - crate::server::render_stream::render_virtual(&mut state, area, true); - - assert!( - cursor.as_ref().is_none_or(|cursor| !cursor.visible), - "cursor: {cursor:?}" - ); - } - - #[test] - fn latest_active_client_drives_shared_size_theme_and_fallback() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (160, 45), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x11, - g: 0x22, - b: 0x33, - }), - ..Default::default() - }, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }), - ..Default::default() - }, - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.effective_size, (160, 45)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&1].host_terminal_theme - ); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.effective_size, (80, 24)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&2].host_terminal_theme - ); - - assert!(server.remove_client(2)); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.effective_size, (160, 45)); - assert_eq!( - server.app.state.host_terminal_theme, - server.clients[&1].host_terminal_theme - ); - } - - #[test] - fn foreground_client_without_host_theme_clears_previous_host_theme() { - let mut server = test_headless_server(); - let known_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x40, - g: 0x50, - b: 0x60, - }), - ..Default::default() - }; - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - known_theme, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.app.state.host_terminal_theme, known_theme); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!( - server.app.state.host_terminal_theme, - crate::terminal_theme::TerminalTheme::default() - ); - } - - #[test] - fn foreground_client_appearance_controls_auto_theme() { - let mut server = test_headless_server(); - server.app.state.theme_runtime.auto_switch = true; - server.app.state.theme_runtime.dark_name = "catppuccin".to_string(); - server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string(); - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: None, - background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }), - ..Default::default() - }, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme { - foreground: None, - background: Some(crate::terminal_theme::RgbColor { - r: 255, - g: 255, - b: 255, - }), - ..Default::default() - }, - None, - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - - assert!(server.promote_client_to_foreground(1)); - assert_eq!(server.app.state.theme_name, "catppuccin"); - - assert!(server.promote_client_to_foreground(2)); - assert_eq!(server.app.state.theme_name, "catppuccin-latte"); - } - - #[test] - fn color_scheme_change_event_is_inert_on_server() { - let mut server = test_headless_server(); - let initial_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 0x10, - g: 0x20, - b: 0x30, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 0x40, - g: 0x50, - b: 0x60, - }), - ..Default::default() - }; - server.app.state.host_terminal_theme = initial_theme; - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - initial_theme, - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(), - }); - - assert!(!changed); - assert_eq!(server.foreground_client_id, None); - assert_eq!(server.clients[&1].host_terminal_theme, initial_theme); - assert_eq!(server.app.state.host_terminal_theme, initial_theme); - } - - #[test] - fn focus_lost_updates_client_without_promoting_foreground() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - }); - - assert!(!changed); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[test] - fn focus_gained_promotes_client_to_foreground() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 2, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - }); - - assert!(changed); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[tokio::test] - async fn foreground_focus_gained_reaches_pane_with_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - })); - assert_eq!( - input_rx.try_recv().expect("forwarded focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b""); - server.clients.insert(1, test_app_client(Some(false), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_focus_batch_only_forwards_events_after_promotion() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(false), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[O\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!( - input_rx - .try_recv() - .expect("focus gained after client promotion"), - Bytes::from_static(b"\x1b[I") - ); - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - } - - #[tokio::test] - async fn background_client_focus_loss_releases_its_owned_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('j'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::FocusLost], - })); - assert_eq!( - input_rx.try_recv().expect("forwarded press"), - Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - input_rx - .try_recv() - .expect("synthetic release from background client"), - Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(server.app.input_leases.is_empty()); - } - - #[tokio::test] - async fn structured_outer_focus_events_reach_reporting_pane() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![ - crate::protocol::ClientInputEvent::FocusGained, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!( - input_rx.try_recv().expect("structured focus gained report"), - Bytes::from_static(b"\x1b[I") - ); - assert_eq!( - input_rx.try_recv().expect("structured focus lost report"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn background_key_makes_later_focus_lost_eligible() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }, - crate::protocol::ClientInputEvent::FocusLost, - ], - })); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!( - input_rx.try_recv().expect("focus lost after promotion"), - Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn structured_non_app_focus_is_ignored_without_suppressing_keys() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - - let mut attached = test_app_client(Some(false), 2); - attached.mode = ClientConnectionMode::TerminalAttach { - terminal_id: "attached".to_owned(), - }; - server.clients.insert(2, attached); - - let mut pending = test_app_client(Some(false), 3); - pending.pending_terminal_attach = true; - server.clients.insert(3, pending); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - for client_id in [2, 3] { - assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { - client_id, - events: vec![crate::protocol::ClientInputEvent::FocusGained], - })); - assert_eq!(server.foreground_client_id, Some(1)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false)); - } - - assert!(matches!( - input_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - )); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 3, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('x'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - assert_eq!(server.foreground_client_id, Some(3)); - } - - #[test] - fn terminal_attach_resize_preserves_known_cell_size_when_pixels_are_omitted() { - with_terminal_session_test_server(|server, _terminal_id, terminal_id, _pane_id| { - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ); - client.mode = ClientConnectionMode::TerminalAttach { - terminal_id: terminal_id.clone(), - }; - server.clients.insert(1, client); - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 100, - rows: 30, - cell_width_px: 0, - cell_height_px: 0, - })); - - assert_eq!( - server - .runtime_for_terminal_id_string(&terminal_id) - .unwrap() - .pixel_size(), - Some((1_000, 600)) - ); - assert_eq!( - server.clients[&1].cell_size, - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - } - ); - }); - } - - #[tokio::test] - async fn passive_mouse_motion_forwards_without_requesting_render() { - let mut server = test_headless_server(); - let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); - server.clients.insert(1, test_app_client(Some(true), 1)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - let baseline = FrameData { - cells: Vec::new(), - width: 0, - height: 0, - cursor: None, - hyperlinks: Vec::new(), - graphics: Vec::new(), - }; - let client = server.clients.get_mut(&1).unwrap(); - let prepared = client - .render_state - .prepare_frame(baseline.clone()) - .expect("new semantic baseline"); - client.render_state.commit_sent_frame(prepared); - let pane = server.app.state.view.pane_infos[0].clone(); - let column = pane.inner_rect.x + 2; - let row = pane.inner_rect.y + 3; - let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes(); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: input, - })); - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert_eq!( - server.clients[&1].render_state.last_frame(), - Some(&baseline) - ); - } - - #[test] - fn background_mouse_motion_promotes_once_then_becomes_render_neutral() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::Terminal; - server.clients.insert(1, test_app_client(Some(true), 1)); - server.clients.insert(2, test_app_client(Some(true), 2)); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - let motion = || ServerEvent::ClientInputEvents { - client_id: 2, - events: vec![crate::protocol::ClientInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - column: 10, - row: 5, - modifiers: 0, - }], - }; - - assert!(server.handle_server_event(motion())); - assert_eq!(server.foreground_client_id, Some(2)); - assert!(!server.handle_server_event(motion())); - } - - #[test] - fn mouse_motion_in_hover_modes_requires_render() { - let events = [crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: MouseEventKind::Moved, - column: 10, - row: 5, - modifiers: KeyModifiers::empty(), - }, - )]; - - assert!(events_are_render_neutral_mouse_motion( - &events, - crate::app::Mode::Terminal - )); - for mode in [ - crate::app::Mode::GlobalMenu, - crate::app::Mode::ContextMenu, - crate::app::Mode::Navigator, - ] { - assert!(!events_are_render_neutral_mouse_motion(&events, mode)); - } - } - - fn install_focused_test_runtime( - server: &mut HeadlessServer, - terminal_bytes: &[u8], - ) -> tokio::sync::mpsc::Receiver { - let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - terminal_bytes, - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - input_rx - } - - fn test_app_client(outer_terminal_focus: Option, last_activity: u64) -> ClientConnection { - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - outer_terminal_focus, - last_activity, - RenderEncoding::SemanticFrame, - None, - ) - } - - #[test] - fn foreground_client_focus_event_updates_app_focus_state() { - let mut server = test_headless_server(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let changed = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[O".to_vec(), - }); - - assert!(!changed); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); - assert_eq!(server.app.state.outer_terminal_focus, Some(false)); - } - - #[test] - fn app_client_lone_escape_closes_navigate_mode() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Navigate; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b".to_vec(), - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); - } - - #[test] - fn semantic_client_input_events_route_through_app_input() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::Onboarding; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Enter, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Settings); - assert_eq!( - server.app.state.settings.section, - crate::app::state::SettingsSection::Integrations - ); - } - - #[test] - fn semantic_client_escape_closes_keybind_help() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::KeybindHelp; - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::Navigate); - } - - #[test] - fn semantic_client_down_scrolls_keybind_help() { - let mut server = test_headless_server(); - server.app.state.mode = crate::app::Mode::KeybindHelp; - server.clients.insert( - 1, - ClientConnection::new( - (100, 30), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - assert!(server.app.state.keybind_help_max_scroll() > 0); - assert!(server.handle_server_event(ServerEvent::ClientInputEvents { - client_id: 1, - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Down, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }], - })); - - assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp); - assert_eq!(server.app.state.keybind_help.scroll, 1); - } - - #[tokio::test] - async fn split_default_background_response_updates_theme_without_forwarding_tail() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(true), - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let _ = server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b]".to_vec(), - }); - assert!(rx.try_recv().is_err()); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"11;#123456\x07".to_vec(), - })); - - assert!(rx.try_recv().is_err()); - assert_eq!( - server.clients[&1].host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - server.app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - } - - #[tokio::test] - async fn render_and_stream_uses_each_client_terminal_size() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].runtimes.insert( - active_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"active"), - ); - workspace.tabs[background_tab].runtimes.insert( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"background"), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer(); - let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(desktop_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (44, 20), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(mobile_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - server.render_and_stream(); - - let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame")); - let mobile_frame = read_server_frame(mobile_rx.recv().expect("mobile frame")); - - assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40)); - assert_eq!((mobile_frame.width, mobile_frame.height), (44, 20)); - let mobile_text = frame_text(&mobile_frame); - let mut mobile_rows = mobile_text.lines(); - let mobile_header = mobile_rows.by_ref().take(2).collect::(); - let mobile_surface = mobile_rows.collect::(); - assert!(mobile_header.contains("test"), "header: {mobile_header:?}"); - assert!( - mobile_surface.contains("active"), - "surface: {mobile_surface:?}" - ); - assert!(!mobile_surface.contains("background")); - - let foreground_terminal_area = Rect::new(26, 1, 94, 39); - let expected_pane_size = ( - foreground_terminal_area.height, - foreground_terminal_area.width.saturating_sub(1), - ); - assert_eq!( - server.app.state.view.layout, - crate::app::state::ViewLayout::Desktop - ); - assert_eq!(server.app.state.view.mobile_header_rect, Rect::default()); - assert_eq!( - server.app.state.view.terminal_area, - foreground_terminal_area - ); - assert_eq!( - server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(), - expected_pane_size - ); - assert_eq!( - server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane] - .current_size(), - expected_pane_size - ); - } - - #[tokio::test] - async fn resize_shared_runtime_resizes_background_tabs() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let active_pane = workspace.tabs[0].root_pane; - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.tabs[0].runtimes.insert( - active_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - workspace.tabs[background_tab].runtimes.insert( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - let terminal_area = server.app.state.view.terminal_area; - let expected = (terminal_area.height, terminal_area.width.saturating_sub(1)); - assert_eq!( - server - .app - .state - .runtime_for_pane(&server.app.terminal_runtimes, active_pane) - .unwrap() - .current_size(), - expected - ); - assert_eq!( - server - .app - .state - .runtime_for_pane(&server.app.terminal_runtimes, background_pane) - .unwrap() - .current_size(), - expected - ); - } - - #[test] - fn terminal_attach_disconnect_restores_app_pane_size() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - let _runtime_guard = rt.enter(); - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); - let terminal_id_string = terminal_id.to_string(); - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.terminal_runtimes.insert( - terminal_id.clone(), - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), - ); - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - None, - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - let expected_app_size = server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(); - assert_ne!(expected_app_size, (24, 80)); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 2, - cols: 80, - rows: 24, - cell_width_px: 0, - cell_height_px: 0, - render_encoding: RenderEncoding::TerminalAnsi, - keybindings: None, - direct_attach_requested: true, - direct_graphics: false, - writer, - })); - assert!( - server.handle_server_event(ServerEvent::ClientAttachTerminal { - client_id: 2, - terminal_id: terminal_id_string.clone(), - takeover: false, - }) - ); - assert_eq!(server.foreground_client_id, Some(1)); - assert!(server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - (24, 80) - ); - - assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); - - assert!(!server - .app - .state - .direct_attach_resize_locks - .contains(&terminal_id)); - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .expect("runtime") - .current_size(), - expected_app_size - ); - drop(server); - drop(_runtime_guard); - rt.shutdown_timeout(Duration::from_millis(100)); - } - - #[test] - fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 1); - assert_eq!((frame.width, frame.height), (80, 24)); - assert!(frame.full); - assert!(!frame.bytes.is_empty()); - } - other => panic!("expected terminal frame, got {other:?}"), - } - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - } - - #[test] - fn render_and_stream_sends_large_terminal_frame_for_terminal_ansi_client() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (278, 85), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 1); - assert_eq!((frame.width, frame.height), (278, 85)); - assert!(frame.full); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols: 710, - rows: 202, - cell_width_px: 0, - cell_height_px: 0, - })); - server.render_and_stream(); - - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("large terminal frame"), - ) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 2); - assert_eq!((frame.width, frame.height), (710, 202)); - assert!(frame.full); - assert!(!frame.bytes.is_empty()); - } - other => panic!("expected terminal frame, got {other:?}"), - } - - server.app.state.mode = crate::app::Mode::Navigate; - server.render_and_stream(); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("follow-up terminal frame"), - ) { - ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 3), - other => panic!("expected terminal frame, got {other:?}"), - } - } - - #[test] - fn terminal_ansi_input_does_not_reset_blit_baseline() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - - assert!(!server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: Vec::new(), - })); - server.render_and_stream(); - - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn outer_focus_gained_repaints_terminal_ansi_without_clearing() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - server.render_and_stream(); - - match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { - ServerMessage::Terminal(frame) => { - assert_eq!(frame.seq, 2); - assert!(frame.full); - assert!(!frame.bytes.windows(4).any(|bytes| bytes == b"\x1b[2J")); - } - other => panic!("expected terminal frame, got {other:?}"), - } - } - - #[tokio::test] - async fn outer_focus_gained_client_render_pending_survives_semantic_render_queue_full() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial semantic frame"); - - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - server.clients[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - - server.render_and_stream(); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); - server.render_and_stream(); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::Frame(_) - )); - } - - #[test] - fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() { - let mut server = test_headless_server(); - server.app.state.redraw_on_focus_gained = false; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial terminal frame"); - - server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - }); - server.render_and_stream(); - - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - } - - #[test] - fn outer_focus_gained_does_not_mark_semantic_render_pending_when_disabled() { - let mut server = test_headless_server(); - server.app.state.redraw_on_focus_gained = false; - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 1, - data: b"\x1b[I".to_vec(), - })); - - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - assert!(!server.app.full_redraw_pending); - assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); - assert_eq!(server.app.state.outer_terminal_focus, Some(true)); - } - - #[test] - fn full_render_queue_does_not_advance_terminal_ansi_baseline() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - client_tx.test_fill_render(queued); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 0 - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn writer_drained_retries_pending_terminal_ansi_render() { - let mut server = test_headless_server(); - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - client_tx.test_fill_render(queued); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::TerminalAnsi, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - - server.render_and_stream(); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 })); - server.render_and_stream(); - - match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) { - ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1), - other => panic!("expected terminal frame, got {other:?}"), - } - assert_eq!( - server - .clients - .get(&1) - .unwrap() - .render_state - .terminal_seq() - .unwrap(), - 1 - ); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::None - ); - } - - #[test] - fn render_and_stream_skips_identical_frame_sends() { - let mut server = test_headless_server(); - server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - - server.render_and_stream(); - let first = client_rx.recv_timeout(Duration::from_millis(100)); - assert!(first.is_ok(), "expected first frame to be sent"); - - server.render_and_stream(); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "identical frame should not be sent twice" - ); - } - - #[test] - fn visible_source_wakes_pending_hidden_work() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(!server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(visible_pane)); - assert!(server.has_pending_presentation_work(false, false)); - } - - #[test] - fn inactive_tab_pty_source_is_hidden_until_tab_focus() { - let (server, background_pane) = hidden_pty_visibility_test_server(&[]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - let (mut server, background_pane) = - hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]); - let sources = HashSet::from([background_pane]); - assert!(!server.pty_sources_visible_to_any_render_target(&sources)); - - server.app.state.workspaces[0].switch_tab(1); - assert!(server.pty_sources_visible_to_any_render_target(&sources)); - } - - #[tokio::test] - async fn hidden_pty_output_appears_after_switching_to_its_tab() { - let mut server = test_headless_server(); - let mut workspace = crate::workspace::Workspace::test_new("test"); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - workspace.insert_test_runtime( - background_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"), - ); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); - server.render_and_stream(); - let _initial_frame = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, background_pane) - .expect("background runtime"); - runtime.test_process_pty_bytes(b"\rhidden-update"); - assert!(server.app.render_dirty.request_pty(background_pane)); - let request = server.app.render_dirty.take(); - let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) { - PtyRenderState::Visible - } else { - PtyRenderState::Hidden - }; - assert_eq!( - retained_render_plan(RetainedRenderInput { - needs_full_render: false, - needs_graphics_render: false, - pty, - }), - RetainedRenderPlan::HiddenPty - ); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let visible_frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("frame after tab switch"), - ); - assert!(frame_text(&visible_frame).contains("hidden-update")); - } - - #[test] - fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_app_client() { - let (mut server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]); - assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let terminal_id = server.app.state.workspaces[0] - .terminal_id(background_pane) - .expect("background terminal id") - .to_string(); - let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new_with_mode( - ClientConnectionMode::TerminalObserve { terminal_id }, - None, - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - false, - Some(client_tx), - ), - ); - - assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); - - let hidden_pane = server.app.state.workspaces[0].tabs[0].root_pane; - server.sync_immediate_pty_sources(); - assert!(server.app.render_dirty.request_pty(background_pane)); - assert!(server.has_pending_presentation_work(false, false)); - assert!(server.app.render_dirty.request_pty(hidden_pane)); - } - - #[tokio::test] - async fn retained_pty_update_streams_dirty_row_from_last_frame() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.render_and_stream(); - let first = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - assert!(first.cells.iter().any(|cell| cell.symbol == "a")); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame"), - ); - assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); - assert_eq!((patched.width, patched.height), (80, 24)); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_popup_is_visible() { - let (mut server, client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup-aaaa"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial popup frame"), - ); - assert!(frame_text(&initial).contains("popup-aaaa")); - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - server.render_and_stream(); - let updated = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full popup fallback frame"), - ); - assert!(frame_text(&updated).contains("Zopup-aaaa")); - } - - #[tokio::test] - async fn popup_forces_host_mouse_capture_for_headless_client() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.app.state.mouse_capture = false; - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_mouse_capture_mode(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("mouse capture message") - ), - ServerMessage::MouseCapture { enabled: true, .. } - )); - } - - #[tokio::test] - async fn command_mode_updates_headless_client_keyboard_flags() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - - server.app.state.mode = crate::app::Mode::Prefix; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("command-mode keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn focused_report_all_pane_updates_headless_client_keyboard_flags() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u"); - server.app.install_test_popup_runtime(popup_runtime); - - server.stream_host_keyboard_enhancement_flags(); - - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: true } - )); - - assert!(server.app.close_popup_pane()); - server.app.state.mode = crate::app::Mode::Terminal; - server.stream_host_keyboard_enhancement_flags(); - assert!(matches!( - read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("IME-compatible keyboard enhancement message") - ), - ServerMessage::KittyKeyboardReportAll { enabled: false } - )); - } - - #[tokio::test] - async fn virtual_render_uses_popup_cursor() { - let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H"); - let popup_runtime = - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[4;5H"); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - - let (_, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - let (_, inner) = - crate::ui::popup_pane_rects(&server.app.state, server.app.state.view.terminal_area) - .unwrap(); - let expected = server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .cursor_state(inner, true) - .unwrap(); - - assert_eq!( - cursor, - Some(crate::protocol::CursorState { - x: expected.x, - y: expected.y, - visible: expected.visible, - shape: expected.shape, - }) - ); - } - - #[tokio::test] - async fn virtual_render_does_not_resize_directly_attached_popup() { - let (mut server, _client_rx, _) = retained_test_server(b"tiled"); - let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(50, 13, b""); - let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); - server - .app - .state - .direct_attach_resize_locks - .insert(terminal_id.clone()); - - let _ = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut server.app.state, - &server.app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 80, 24), - true, - crate::kitty_graphics::HostCellSize::default(), - ); - - assert_eq!( - server - .app - .terminal_runtimes - .get(&terminal_id) - .unwrap() - .current_size(), - (13, 50) - ); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_toast_is_visible() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_owned(), - context: "background · 2".to_owned(), - position: None, - target: None, - }); - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - assert!( - frame_text(&initial).contains("pi needs attention"), - "expected initial full frame to include toast text" - ); - - let toast_row = server.app.state.view.toast_hit_area.y; - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let pane_row = toast_row - .checked_sub(inner_rect.y) - .expect("toast should overlap the pane") - + 1; - assert!(pane_row <= inner_rect.height); - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes()); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "retained path should not stream a frame that can overwrite toast cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_declines_while_copy_feedback_is_visible() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_owned(), - }); - server.render_and_stream(); - let initial = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ); - let initial_text = frame_text(&initial); - assert!( - initial_text.contains("copied to clipboard"), - "expected initial full frame to include copy feedback" - ); - - let feedback_row = initial_text - .lines() - .position(|line| line.contains("copied to clipboard")) - .expect("copy feedback row") as u16; - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let pane_row = feedback_row - .checked_sub(inner_rect.y) - .expect("copy feedback should overlap the pane") - + 1; - assert!(pane_row <= inner_rect.height); - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes()); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!( - client_rx.recv_timeout(Duration::from_millis(50)).is_err(), - "retained path should not stream a frame that can overwrite copy feedback cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_matches_full_render_frame() { - let initial = b"\x1b[6 qleft \xe4\xb8\xad"; - let update = b"\r\x1b[44mZ\x1b[0m"; - let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); - let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); - - retained_server.render_and_stream(); - let _ = retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial retained baseline"); - full_server.render_and_stream(); - let _ = full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial full baseline"); - - retained_server - .app - .state - .runtime_for_pane_in_workspace( - &retained_server.app.terminal_runtimes, - 0, - retained_pane_id, - ) - .expect("retained runtime") - .test_process_pty_bytes(update); - full_server - .app - .state - .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) - .expect("full runtime") - .test_process_pty_bytes(update); - - assert!(retained_server.render_retained_pty_update_and_stream()); - full_server.render_and_stream(); - - let retained_frame = read_server_frame( - retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame"), - ); - let full_frame = read_server_frame( - full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full frame"), - ); - assert_frame_data_eq(&retained_frame, &full_frame); - } - - #[tokio::test] - async fn retained_pty_update_streams_cursor_only_change() { - let initial = b"abcd"; - let update = b"\x1b[D"; - let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); - let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); - - retained_server.render_and_stream(); - let _ = retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial retained baseline"); - full_server.render_and_stream(); - let _ = full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial full baseline"); - - retained_server - .app - .state - .runtime_for_pane_in_workspace( - &retained_server.app.terminal_runtimes, - 0, - retained_pane_id, - ) - .expect("retained runtime") - .test_process_pty_bytes(update); - full_server - .app - .state - .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) - .expect("full runtime") - .test_process_pty_bytes(update); - - assert!(retained_server.render_retained_pty_update_and_stream()); - full_server.render_and_stream(); - - let retained_frame = read_server_frame( - retained_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained cursor frame"), - ); - let full_frame = read_server_frame( - full_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full cursor frame"), - ); - assert_frame_data_eq(&retained_frame, &full_frame); - } - - #[tokio::test] - async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - server.app.state.mode = crate::app::Mode::Navigate; - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.app.state.mode = crate::app::Mode::Terminal; - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame after safe mode"), - ); - assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); - } - - #[tokio::test] - async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.full_redraw_pending = true; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full redraw frame"); - assert!(!server.app.full_redraw_pending); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - } - - #[tokio::test] - async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() { - let (mut server, client_rx, pane_id) = retained_test_server(b"link"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - let inner_rect = server.app.state.view.pane_infos[0].inner_rect; - let client = server.clients.get_mut(&1).unwrap(); - let mut frame = client.render_state.last_frame().unwrap().clone(); - frame.hyperlinks = vec!["https://example.com".to_owned()]; - let hyperlink_idx = - usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x); - frame.cells[hyperlink_idx].hyperlink = Some(0); - let prepared = client - .render_state - .prepare_frame(frame) - .expect("hyperlink frame differs"); - client.render_state.commit_sent_frame(prepared); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rplain"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - - server.render_and_stream(); - let full = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("full frame after hyperlink overwrite"), - ); - assert!( - full.cells.iter().all(|cell| cell.hyperlink.is_none()), - "full render should clear overwritten hyperlink cells" - ); - } - - #[tokio::test] - async fn retained_pty_update_allows_dirty_row_that_creates_plain_url() { - let (mut server, client_rx, pane_id) = retained_test_server(b"plain"); - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rhttps://example.com/new"); - - assert!(server.render_retained_pty_update_and_stream()); - let patched = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame after plain URL"), - ); - assert!( - patched.hyperlinks.is_empty(), - "retained render should not synthesize plain URL hyperlink metadata" - ); - } - - #[tokio::test] - async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(server.render_retained_pty_update_and_stream()); - let retained = read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("retained frame with kitty enabled"), - ); - assert!(retained.cells.iter().any(|cell| cell.symbol == "Z")); - } - - #[tokio::test] - async fn retained_pty_update_declines_when_graphics_cache_has_content() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - server.app.state.kitty_graphics_enabled = true; - let client = server.clients.get_mut(&1).unwrap(); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - - server.render_and_stream(); - let _ = client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"); - server - .clients - .get_mut(&1) - .unwrap() - .graphics_cache - .test_mark_non_empty(); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[tokio::test] - async fn full_redraw_pending_survives_full_render_queue_full() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) - .expect("serialize dummy message"); - server.clients[&1] - .writer - .as_ref() - .unwrap() - .test_fill_render(queued); - server.app.full_redraw_pending = true; - - server.render_and_stream(); - - assert!(server.app.full_redraw_pending); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), - ServerMessage::ReloadSoundConfig - )); - - let runtime = server - .app - .state - .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - runtime.test_process_pty_bytes(b"\rZ"); - - assert!(!server.render_retained_pty_update_and_stream()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - } - - #[test] - fn client_config_reload_request_refreshes_attached_clients() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.app.state.request_client_config_reload = true; - - server.drain_client_config_reload_request(); - - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("client config reload message"), - ) { - ServerMessage::ReloadSoundConfig => {} - other => panic!("expected ReloadSoundConfig, got {other:?}"), - } - assert!(!server.app.state.request_client_config_reload); - } - - #[test] - fn terminal_bell_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 3, - }); - - assert!(!changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground terminal bell message"), - ) { - ServerMessage::TerminalBell { count } => assert_eq!(count, 3), - other => panic!("expected terminal bell message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive terminal bells" - ); - - server.foreground_client_id = None; - server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { - pane_id: crate::layout::PaneId::from_raw(1), - count: 1, - }); - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "bells without a foreground client must not be retained" - ); - } - - #[test] - fn clipboard_write_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert_eq!( - server - .app - .state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground clipboard message"), - ) { - ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), - other => panic!("expected clipboard message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive clipboard writes" - ); - } - - #[test] - fn clipboard_write_without_foreground_client_does_not_show_feedback() { - let mut server = test_headless_server(); - server.foreground_client_id = None; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert!( - server.app.state.copy_feedback.is_none(), - "clipboard feedback should only show when a foreground client can receive the write" - ); - } - - #[test] - fn clipboard_write_failed_foreground_send_does_not_show_feedback() { - let mut server = test_headless_server(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - drop(foreground_control_rx); - foreground_tx.test_close(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(1); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { - content: b"test".to_vec(), - }); - - assert!(changed); - assert!( - server.app.state.copy_feedback.is_none(), - "clipboard feedback should only show after the foreground client receives the write" - ); - assert!( - !server.clients.contains_key(&1), - "failed targeted send should remove the broken foreground client" - ); - } - - #[test] - fn prefix_input_source_targets_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - // Drain any setup messages (e.g. mouse-capture sync) before exercising the event. - while foreground_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - let changed = server - .handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true }); - - assert!(changed); - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground prefix input-source message"), - ) { - ServerMessage::PrefixInputSource { active } => assert!(active), - other => panic!("expected prefix input-source message, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive prefix input-source changes" - ); - } - - #[test] - fn headless_app_keeps_prefix_input_source_switch_off_process() { - // An App-internal drain (e.g. the exhaustive drain at the top of - // handle_api_request) can consume a queued PrefixInputSource intent - // before the forwarding drain sees it. The headless App must treat the - // event as inert instead of switching the host input source from the - // server process. - struct CountingPrefixInputSource(std::rc::Rc>); - impl crate::platform::PrefixInputSource for CountingPrefixInputSource { - fn switch_to_ascii(&mut self) { - self.0.set(self.0.get() + 1); - } - fn restore(&mut self) { - self.0.set(self.0.get() + 1); - } - } - - let mut server = test_headless_server(); - let calls = std::rc::Rc::new(std::cell::Cell::new(0)); - server - .app - .set_prefix_input_source(Box::new(CountingPrefixInputSource(calls.clone()))); - - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: true }); - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: false }); - assert_eq!( - calls.get(), - 0, - "headless server must not apply the host input-source switch" - ); - - // Sanity: the same event does apply once the flag is on (monolithic semantics). - server.app.local_input_source_switch = true; - server - .app - .handle_internal_event(AppEvent::PrefixInputSource { active: true }); - assert_eq!(calls.get(), 1); - } - - #[test] - fn client_local_notifications_target_foreground_client_only() { - let mut server = test_headless_server(); - let (background_tx, background_control_rx, _background_rx) = test_client_writer(); - let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(background_tx), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_tx), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!(server.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: "pi finished".to_string(), - body: Some("workspace 1".to_string()), - })); - - match read_server_message( - foreground_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("foreground toast message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Toast); - assert_eq!(message, "pi finished"); - assert_eq!(body.as_deref(), Some("workspace 1")); - } - other => panic!("expected toast notify, got {other:?}"), - } - assert!( - background_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "background client should not receive client-local notifications" - ); - } - - #[test] - fn oversized_paste_rejection_notifies_only_the_sending_client() { - let mut server = test_headless_server(); - let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); - let (foreground_writer, foreground_control_rx, _foreground_render_rx) = - test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (120, 40), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(sender_writer), - ), - ); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(foreground_writer), - ), - ); - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - - assert!( - !server.handle_server_event(ServerEvent::ClientPasteRejected { - client_id: 1, - size: 5_000_012, - max: 1_048_576, - }) - ); - - match read_server_message( - sender_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("sending client rejection notification"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Toast); - assert_eq!(message, "Paste rejected"); - assert_eq!( - body.as_deref(), - Some("Input message is 5000012 bytes; Herdr's limit is 1048576 bytes") - ); - } - other => panic!("expected paste rejection notification, got {other:?}"), - } - assert!( - foreground_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "foreground client must not receive another client's rejection" - ); - assert_eq!(server.foreground_client_id, Some(2)); - assert_eq!(server.clients.len(), 2); - assert!(server.app.state.toast.is_none()); - } - - #[test] - fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { - version: "9.9.9".to_string(), - install_command: "herdr update".into(), - }); - - assert!(changed); - assert!(server.app.state.toast.is_some()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "herdr delivery should render in-frame instead of forwarding a client-local notification" - ); - } - - #[test] - fn system_toast_delivery_forwards_system_notify_kind() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { - version: "9.9.9".to_string(), - install_command: "herdr update".into(), - }); - - assert!(changed); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("system toast message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "v9.9.9 available"); - assert_eq!( - body.as_deref(), - Some("detach, run `herdr update`, then follow its restart guidance") - ); - } - other => panic!("expected system toast notify, got {other:?}"), - } - } - - #[test] - fn notification_show_api_forwards_system_notification_to_foreground_client() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: Some("api workspace".into()), - position: Some(crate::config::ToastHerdrPosition::TopLeft), - sound: api::schema::NotificationShowSound::Request, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api notification message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api sound message"), - ); - - match first { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "build failed"); - assert_eq!(body.as_deref(), Some("api workspace")); - } - other => panic!("expected api notification, got {other:?}"), - } - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Sound); - assert_eq!(message, "agent attention"); - assert!(body.is_none()); - } - other => panic!("expected api sound, got {other:?}"), - } - } - - #[test] - fn notification_show_api_preserves_colon_in_forwarded_title() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build: failed".into(), - body: Some("api workspace".into()), - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api notification message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "build: failed"); - assert_eq!(body.as_deref(), Some("api workspace")); - } - other => panic!("expected api notification, got {other:?}"), - } - } - - #[test] - fn notification_show_api_validates_empty_title_before_disabled_delivery() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "\n\t".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(parsed.error.code, "invalid_params"); - assert_eq!(parsed.error.message, "notification title is empty"); - } - - #[test] - fn notification_show_api_reports_no_foreground_client() { - let mut server = test_headless_server(); - server.foreground_client_id = None; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::Request, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: false, - reason: api::schema::NotificationShowReason::NoForegroundClient, - } - ); - } - - #[test] - fn notification_show_api_herdr_toast_expires_headless() { - let mut server = test_headless_server(); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::None, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - let deadline = server.app.toast_deadline.expect("api toast deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - assert!(server.app.state.toast.is_none()); - assert!(server.app.toast_deadline.is_none()); - } - - #[test] - fn notification_show_api_forwards_sound_for_herdr_delivery() { - let mut server = test_headless_server(); - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - assert!( - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "notify".into(), - method: api::schema::Method::NotificationShow( - api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: api::schema::NotificationShowSound::Done, - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }) - ); - - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - api::schema::ResponseResult::NotificationShow { - shown: true, - reason: api::schema::NotificationShowReason::Shown, - } - ); - match read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("api sound message"), - ) { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::Sound); - assert_eq!(message, "agent done"); - assert!(body.is_none()); - } - other => panic!("expected api sound, got {other:?}"), - } - } - - #[test] - fn startup_idle_does_not_forward_completion() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("active"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 0; - server.app.state.sound.enabled = true; - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { - pane_id, - agent: crate::detect::Agent::Pi, - observed_at: Instant::now(), - }) - ); - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - while client_control_rx - .recv_timeout(Duration::from_millis(20)) - .is_ok() - {} - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }) - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "startup readiness should not forward a completion notification" - ); - } - - #[test] - fn delayed_agent_notification_forwards_after_deadline() { - let mut server = test_headless_server(); - let background = crate::workspace::Workspace::test_new("background"); - let pane_id = background.tabs[0].root_pane; - let foreground = crate::workspace::Workspace::test_new("foreground"); - server.app.state.workspaces = vec![background, foreground]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(1); - server.app.state.selected = 1; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 1; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let changed = server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Blocked, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }); - - assert!(changed); - assert!(server.app.state.toast.is_none()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "delayed transition should not notify immediately" - ); - - let deadline = server - .app - .state - .next_pending_agent_notification_deadline() - .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed sound message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed toast message"), - ); - - assert!(matches!( - first, - ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - .. - } - )); - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "pi needs attention"); - assert_eq!(body.as_deref(), Some("background · 1")); - } - other => panic!("expected delayed system toast, got {other:?}"), - } - assert!(server.app.state.pending_agent_notifications.is_empty()); - } - - #[test] - fn delayed_active_tab_unfocused_agent_notification_forwards_after_deadline() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("active"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.ensure_test_terminals(); - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.mode = crate::app::Mode::Terminal; - server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; - server.app.state.toast_config.delay_seconds = 1; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - assert!( - server.handle_internal_event_with_forwarding(AppEvent::StateChanged { - pane_id, - agent: Some(crate::detect::Agent::Pi), - state: crate::detect::AgentState::Blocked, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: Instant::now(), - }) - ); - assert!(server.app.state.toast.is_none()); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "delayed transition should not notify immediately" - ); - - let deadline = server - .app - .state - .next_pending_agent_notification_deadline() - .expect("pending notification deadline"); - assert!(server.handle_scheduled_tasks_headless(deadline, false)); - - let first = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed sound message"), - ); - let second = read_server_message( - client_control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("delayed toast message"), - ); - - assert!(matches!( - first, - ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - .. - } - )); - match second { - ServerMessage::Notify { - kind, - message, - body, - } => { - assert_eq!(kind, protocol::NotifyKind::SystemToast); - assert_eq!(message, "pi needs attention"); - assert_eq!(body.as_deref(), Some("active · 1")); - } - other => panic!("expected delayed system toast, got {other:?}"), - } - } - - #[test] - fn stale_api_agent_report_does_not_forward_done_sound() { - let mut server = test_headless_server(); - let background = crate::workspace::Workspace::test_new("background"); - let pane_id = background.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", background.id); - let foreground = crate::workspace::Workspace::test_new("foreground"); - server.app.state.workspaces = vec![background, foreground]; - server.app.state.ensure_test_terminals(); - let terminal_id = server.app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .attached_terminal_id - .clone(); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_detected_state( - Some(crate::detect::Agent::Pi), - crate::detect::AgentState::Idle, - ); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:pi".into(), - agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path( - std::env::current_dir() - .unwrap() - .join("headless-pi-session.jsonl") - .display() - .to_string(), - ) - .unwrap(), - }); - server - .app - .state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_hook_authority( - "herdr:pi".into(), - "pi".into(), - crate::detect::AgentState::Working, - None, - Some(20), - ); - server.app.state.active = Some(1); - server.app.state.selected = 1; - server.app.state.mode = crate::app::Mode::Terminal; - - let (client_tx, client_control_rx, _client_rx) = test_client_writer(); - server.clients.insert( - 1, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - crate::terminal_theme::TerminalTheme::default(), - None, - 1, - RenderEncoding::SemanticFrame, - Some(client_tx), - ), - ); - server.foreground_client_id = Some(1); - server.sync_foreground_client_state(); - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "stale".into(), - method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { - pane_id: public_pane_id, - source: "herdr:pi".into(), - agent: "pi".into(), - state: api::schema::PaneAgentState::Idle, - message: None, - seq: Some(19), - agent_session_id: None, - agent_session_path: None, - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - - assert!(changed); - assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); - assert_eq!( - server.app.state.terminals.get(&terminal_id).unwrap().state, - crate::detect::AgentState::Working - ); - assert!( - client_control_rx - .recv_timeout(Duration::from_millis(50)) - .is_err(), - "stale idle report must not forward a done sound" - ); - } - - /// Verify that no direct calls to `self.app.handle_internal_event` - /// (or its `handle_internal_event_with_prefix_sync` wrapper) exist - /// outside of `handle_internal_event_with_forwarding` in this - /// module. This ensures the forwarding bypass cannot be reintroduced. - /// - /// The search pattern looks for `handle_internal_event` calls that - /// are NOT inside the `handle_internal_event_with_forwarding` method. - #[test] - fn no_handle_internal_event_bypass_in_module() { - let source = include_str!("headless.rs"); - - // Find all lines containing handle_internal_event - let mut bypass_lines: Vec = Vec::new(); - let mut inside_forwarding_method = false; - let mut forwarding_method_brace_depth = 0u32; - - for (i, line) in source.lines().enumerate() { - let line_num = i + 1; - - // Track when we're inside handle_internal_event_with_forwarding - if line.contains("fn handle_internal_event_with_forwarding") { - inside_forwarding_method = true; - forwarding_method_brace_depth = 0; - } - - if inside_forwarding_method { - // Count braces to track when we exit the method - for ch in line.chars() { - match ch { - '{' => forwarding_method_brace_depth += 1, - '}' => { - forwarding_method_brace_depth = - forwarding_method_brace_depth.saturating_sub(1); - if forwarding_method_brace_depth == 0 { - inside_forwarding_method = false; - } - } - _ => {} - } - } - } else if (line.contains("self.app.handle_internal_event(") - || line.contains("self.app.handle_internal_event_with_prefix_sync(")) - && !line.trim().starts_with("///") - && !line.contains("contains(") - { - // Direct call to handle_internal_event outside the forwarding method - bypass_lines.push(format!("line {}: {}", line_num, line.trim())); - } - } - - assert!( - bypass_lines.is_empty(), - "Found direct calls to self.app.handle_internal_event outside \ - handle_internal_event_with_forwarding (bypass risk):\n {}", - bypass_lines.join("\n ") - ); - } -} +mod tests; diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs new file mode 100644 index 00000000..638b1b2d --- /dev/null +++ b/src/server/headless/bootstrap.rs @@ -0,0 +1,218 @@ +use super::*; + +/// Run the headless server. This is the entry point called from main.rs. +pub fn run_server() -> io::Result<()> { + init_logging(); + crate::platform::raise_server_nofile_limit(); + + let args: Vec = std::env::args().collect(); + if args.get(2).map(String::as_str) == Some("--handoff-import") { + let socket_path = args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?; + let token = args + .get(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?; + return run_handoff_import_server(&socket_path, token); + } + + let loaded_config = config::Config::load(); + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + // Start the JSON API socket server. + let _api_server = match api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("api socket: {}", api::socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + // Create the App (with AppState, event channels, etc.). + let mut app = app::App::new( + &loaded_config.config, + app::AppPolicy::PRODUCTION, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub, + ); + seed_startup_workspace_if_empty(&mut app); + + // Create the headless server. + let mut server = match HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(_api_server), + should_quit, + ) { + Ok(server) => server, + Err(err) if err.kind() == io::ErrorKind::AddrInUse => { + eprintln!("error: herdr server is already running"); + eprintln!("client socket: {}", client_socket_path().display()); + std::process::exit(1); + } + Err(err) => return Err(err), + }; + + info!( + api_socket = %api::socket_path().display(), + client_socket = %client_socket_path().display(), + "herdr server started" + ); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +fn seed_startup_workspace_if_empty(app: &mut app::App) { + let Some(cwd) = take_startup_cwd() else { + return; + }; + + if !app.state.workspaces.is_empty() { + info!( + cwd = %cwd.display(), + "restored session already has workspaces; ignoring startup cwd" + ); + return; + } + + match app.create_workspace_with_options(cwd.clone(), true) { + Ok(_) => { + info!(cwd = %cwd.display(), "created startup workspace"); + } + Err(err) => { + warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); + app.state.mode = app::Mode::Navigate; + } + } +} + +fn take_startup_cwd() -> Option { + let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; + std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); + (!cwd.is_empty()).then(|| PathBuf::from(cwd)) +} + +#[cfg(unix)] +fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { + let loaded_config = config::Config::load(); + let mut received = crate::server::handoff::receive(socket_path, token)?; + crate::server::handoff::log_import_result(received.manifest.panes.len()); + + let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_hub = api::EventHub::default(); + let should_quit = Arc::new(AtomicBool::new(false)); + + let mut imports = HashMap::new(); + for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { + let pane_id = pane.pane_id; + imports.insert( + pane_id, + crate::handoff_runtime::ImportedHandoffRuntime { + master_fd: fd, + state: pane, + }, + ); + } + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(io::Error::other)?; + + let result = rt.block_on(async { + let app = app::App::new_from_handoff( + &loaded_config.config, + config::config_diagnostic_summary(&loaded_config.diagnostics), + api_rx, + event_hub.clone(), + &received.manifest.snapshot, + &mut imports, + )?; + crate::server::handoff::report_restored(&mut received.stream)?; + if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { + return Err(io::Error::other( + "test handoff import failure after restored", + )); + } + wait_for_old_public_sockets_to_close(Duration::from_secs(5))?; + + let api_server = api::start_server_with_stop_control( + api_tx.clone(), + event_hub.clone(), + should_quit.clone(), + )?; + let mut server = HeadlessServer::new( + app, + &loaded_config.diagnostics, + Some(api_tx.clone()), + Some(api_server), + should_quit, + )?; + // Carried across before any client attaches, so the first title sent is + // the override rather than the configured one it replaced. + server.api_window_title = received.manifest.api_window_title.take(); + crate::server::handoff::report_ready(&mut received.stream)?; + crate::server::handoff::wait_committed(&mut received.stream)?; + server.app.assume_handoff_ownership(); + server.app.unpause_handoff_readers(); + server.pending_handoff_repaint_nudge = true; + if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) { + warn!(err = %err, "failed to report handoff ownership; continuing as owner"); + } + info!("handoff import server started"); + print_ready_message(&api::socket_path(), &client_socket_path()); + server.app.run_plugin_startup_hooks(); + server.run().await + }); + + rt.shutdown_timeout(Duration::from_millis(100)); + crate::logging::shutdown("server"); + result +} + +#[cfg(not(unix))] +fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) +} + +fn print_ready_message(api_socket: &Path, client_socket: &Path) { + eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); + eprintln!("api socket: {}", api_socket.display()); + eprintln!("client socket: {}", client_socket.display()); + eprintln!( + "logs: {}", + crate::session::data_dir() + .join("herdr-server.log") + .display() + ); + eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); +} + +/// Initialize logging for the server process. +fn init_logging() { + crate::logging::init_file_logging("herdr-server.log"); +} diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs new file mode 100644 index 00000000..ab1448c3 --- /dev/null +++ b/src/server/headless/lifecycle.rs @@ -0,0 +1,399 @@ +use super::*; + +const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6); + +pub(super) fn wait_for_live_handoff_response_write( + response_write_complete: Option>, +) { + let Some(response_write_complete) = response_write_complete else { + return; + }; + + match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) { + Ok(()) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + warn!("timed out waiting for live handoff response write; old server exiting"); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + warn!("live handoff response writer disconnected; old server exiting"); + } + } +} + +impl HeadlessServer { + #[cfg(unix)] + pub(super) fn perform_live_handoff( + &mut self, + params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + info!("starting live handoff"); + let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from); + let socket_path = crate::server::handoff::handoff_socket_path(); + let token = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let listener = match crate::server::handoff::bind_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + self.handoff_in_progress = false; + return Err(err); + } + }; + + let mut pane_by_terminal = HashMap::new(); + for ws in &self.app.state.workspaces { + for tab in &ws.tabs { + for (pane_id, pane) in &tab.panes { + pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw()); + } + } + } + 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(); + let _ = reject_pending_client_connections(&self.client_listener); + + let mut paused_terminal_ids = Vec::new(); + for terminal_id in pane_by_terminal.keys() { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + paused_terminal_ids.push(terminal_id.clone()); + } + } + + let snapshot = crate::persist::capture( + &self.app.state.workspaces, + &self.app.state.terminals, + &self.app.terminal_runtimes, + self.app.state.active, + self.app.state.selected, + ); + + let mut handoff_entries = Vec::new(); + for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { + let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { + continue; + }; + let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); + let has_agent_session = self + .app + .state + .terminals + .get(terminal_id) + .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); + if !has_agent_session { + handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); + } + handoff_entries.push((terminal_id.clone(), handoff_runtime)); + } + + let panes = handoff_entries + .iter() + .map(|(_, runtime)| runtime.clone()) + .collect(); + let manifest = crate::server::handoff::manifest_for( + snapshot, + panes, + params.expected_protocol, + params.expected_version, + self.api_window_title.clone(), + ); + let mut import_child = match crate::server::handoff::spawn_handoff_import( + import_exe.as_deref(), + &socket_path, + &token, + ) { + Ok(child) => child, + Err(err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + let child_pid = import_child.id(); + info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server"); + + let mut fds = Vec::new(); + let duplicate_result = (|| { + for (terminal_id, _) in &handoff_entries { + let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { + continue; + }; + fds.push(runtime.duplicate_handoff_fd()?); + } + Ok::<(), io::Error>(()) + })(); + if let Err(err) = duplicate_result { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + let mut stream = match crate::server::handoff::accept_and_validate_on( + listener, + &socket_path, + &token, + &manifest, + ) { + Ok(stream) => stream, + Err(err) => { + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + }; + + let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds); + for fd in fds { + let _ = unsafe { libc::close(fd) }; + } + if let Err(err) = send_result { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(err); + } + + if let Some(api_server) = &self.api_server { + let _ = api_server.remove_socket_file_if_owned(); + } else { + let _ = std::fs::remove_file(crate::api::socket_path()); + } + let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); + if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(io::Error::other(format!( + "handoff replacement server did not become ready: {err}" + ))); + } + if let Err(err) = crate::server::handoff::report_committed(&mut stream) { + crate::server::handoff::cleanup_failed_import_child(&mut import_child); + match self.wait_then_restore_public_sockets_after_failed_handoff() { + Ok(()) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + } + Err(restore_err) => { + self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids); + return Err(io::Error::other(format!( + "handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}" + ))); + } + } + return Err(err); + } + + for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() { + if !pane_by_terminal.contains_key(&terminal_id) { + continue; + } + debug!(terminal = %terminal_id, "preserving pane runtime for handoff"); + runtime.preserve_for_handoff(); + } + crate::server::handoff::wait_owned_ack(&mut stream); + + Ok(()) + } + + pub(super) fn finish_live_handoff_shutdown(&mut self) { + self.shutting_down = true; + self.app.state.should_quit = true; + self.app.policy.persist_session = false; + info!("live handoff completed; old server exiting"); + } + + #[cfg(not(unix))] + pub(super) fn perform_live_handoff( + &mut self, + _params: crate::api::schema::ServerLiveHandoffParams, + ) -> io::Result<()> { + Err(io::Error::other("live handoff is only supported on Unix")) + } + + #[cfg(unix)] + fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let api_tx = self + .api_tx + .clone() + .ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?; + let api_server = api::start_server_with_stop_control( + api_tx, + self.app.event_hub.clone(), + self.should_quit.clone(), + )?; + + let client_path = client_socket_path(); + prepare_socket_path(&client_path)?; + let listener = bind_local_listener(&client_path)?; + restrict_socket_permissions(&client_path)?; + let client_socket_identity = socket_file_identity(&client_path)?; + listener.set_nonblocking(ListenerNonblockingMode::Accept)?; + + self.api_server = Some(api_server); + self.client_listener = listener; + self.client_socket_path = client_path; + self.client_socket_identity = client_socket_identity; + Ok(()) + } + + #[cfg(unix)] + fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> { + let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2); + wait_for_old_public_sockets_to_close(timeout)?; + self.restore_public_sockets_after_failed_handoff() + } + + #[cfg(unix)] + fn rollback_handoff_before_commit( + &mut self, + socket_path: &Path, + paused_terminal_ids: &[crate::terminal::TerminalId], + ) { + for terminal_id in paused_terminal_ids { + if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) { + runtime.set_handoff_reader_paused(false); + } + } + self.handoff_in_progress = false; + let _ = std::fs::remove_file(socket_path); + } + + #[cfg(unix)] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) { + if !self.pending_handoff_repaint_nudge { + return; + } + self.pending_handoff_repaint_nudge = false; + self.app + .terminal_runtimes + .nudge_child_redraw_after_handoff(); + } + + #[cfg(not(unix))] + pub(super) fn nudge_handoff_panes_on_first_client_attach(&mut self) {} + /// Initiates graceful shutdown. + pub(super) fn initiate_shutdown(&mut self) { + if self.shutting_down { + return; + } + info!("server shutdown initiated"); + self.shutting_down = true; + + // Clear client-local host graphics, then send ServerShutdown to all connected clients. + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give client writer threads a moment to flush the shutdown message. + // A short sleep ensures the message is written to the socket before + // we close the connections. + std::thread::sleep(Duration::from_millis(50)); + + // Signal the main loop to exit. + self.should_quit.store(true, Ordering::Release); + self.app.state.should_quit = true; + } + + /// Completes the shutdown sequence: send ServerShutdown to clients, + /// close client connections, remove socket files, and clean up. + pub(super) async fn complete_shutdown(&mut self) -> io::Result<()> { + info!("completing server shutdown"); + self.reject_late_client_connections().await; + + // Send ServerShutdown to all remaining clients. + if !self.clients.is_empty() { + let shutdown_msg = ServerMessage::ServerShutdown { + reason: Some("server is shutting down".to_owned()), + }; + self.send_to_all_clients(shutdown_msg); + + // Give writer threads a moment to flush before closing. + std::thread::sleep(Duration::from_millis(50)); + } + + // Reject only the requests already queued when shutdown reached cleanup. + self.reject_queued_api_requests_for_shutdown(); + + // Close all client connections. + let staged_files = self + .clients + .drain() + .flat_map(|(_, client)| client.staged_clipboard_files) + .collect::>(); + crate::server::clipboard_image::remove_files(staged_files); + + // Remove socket files. + self.cleanup_sockets()?; + + Ok(()) + } + + /// Removes socket files created by the server. + pub(super) fn cleanup_sockets(&self) -> io::Result<()> { + if let Err(err) = + remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) + { + if err.kind() != io::ErrorKind::NotFound { + warn!( + path = %self.client_socket_path.display(), + err = %err, + "failed to remove client socket on shutdown" + ); + } + } + Ok(()) + } +} + +#[cfg(unix)] +pub(super) fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { + let deadline = Instant::now() + timeout; + let api_socket = api::socket_path(); + let client_socket = client_socket_path(); + while Instant::now() < deadline { + let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); + let client_open = + client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); + if !api_open && !client_open { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(io::Error::new( + io::ErrorKind::TimedOut, + "old server sockets did not close before handoff import bind", + )) +} diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs new file mode 100644 index 00000000..3587b5cf --- /dev/null +++ b/src/server/headless/notifications.rs @@ -0,0 +1,674 @@ +use super::*; + +impl HeadlessServer { + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { + self.app + .state + .workspaces + .iter() + .find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .map(|terminal| terminal.state) + }) + }) + .unwrap_or(crate::detect::AgentState::Unknown) + } + + fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option { + self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + .map(str::to_string) + }) + }) + } + + fn forward_semantic_agent_notification( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) -> bool { + if update.suppress_completion { + return false; + } + self.forward_semantic_agent_transition( + update.ws_idx, + update.pane_id, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + update.known_agent.or(update.previous_known_agent), + ) + } + + pub(super) fn forward_semantic_agent_transition( + &mut self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + previous_state: crate::detect::AgentState, + state: crate::detect::AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, + known_agent: Option, + ) -> bool { + let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( + false, + previous_state, + state, + previous_agent_label, + agent_label, + ) else { + return false; + }; + let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { + return false; + }; + let Some(tab_idx) = workspace.find_tab_index_for_pane(pane_id) else { + return false; + }; + let Some(tab_number) = workspace.public_tab_number(tab_idx) else { + return false; + }; + let Some(public_pane_id) = self.app.public_pane_id(ws_idx, pane_id) else { + return false; + }; + let Some(agent_label) = agent_label.or(previous_agent_label) else { + return false; + }; + let (semantic_kind, event_text, sound) = match kind { + crate::app::state::ToastKind::NeedsAttention => ( + protocol::SemanticNotificationKind::NeedsAttention, + "needs attention", + Some(protocol::SemanticNotificationSound::Request), + ), + crate::app::state::ToastKind::Finished => ( + protocol::SemanticNotificationKind::Finished, + "finished", + Some(protocol::SemanticNotificationSound::Done), + ), + crate::app::state::ToastKind::UpdateInstalled => ( + protocol::SemanticNotificationKind::UpdateInstalled, + "updated", + None, + ), + }; + let workspace_id = workspace.id.clone(); + let tab_id = crate::workspace::public_tab_id_for_number(&workspace_id, tab_number); + let workspace_label = + workspace.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = + crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id); + let agent = known_agent + .map(crate::detect::agent_label) + .map(str::to_owned); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: semantic_kind, + title: format!("{agent_label} {event_text}"), + body: non_empty_body(&context), + sound, + agent, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(public_pane_id), + position: None, + }, + )) + } + + fn forward_pane_state_update_notifications_to_clients( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) { + if self.app.state.toast_config.delay_seconds != 0 { + return; + } + + let is_active_tab = self + .app + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + return; + } + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + return; + }; + let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { + return; + }; + let Some(agent_label) = update.agent_label.as_deref() else { + return; + }; + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + ); + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + format!("{agent_label} {event_text}"), + non_empty_body(&context), + ); + } + + pub(super) fn forward_agent_notification_delivery( + &mut self, + delivery: &crate::app::state::AgentNotificationDelivery, + ) { + if let Some(sound) = delivery.sound { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if let Some(toast) = &delivery.client_notification { + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + &toast.title, + non_empty_body(&toast.context), + ); + } + } + } + + pub(super) fn send_notify_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl Into, + body: Option, + ) -> bool { + self.send_to_foreground_client(ServerMessage::Notify { + kind, + message: message.into(), + body, + }) + } + + pub(super) fn send_flat_toast_to_foreground_client( + &mut self, + kind: protocol::NotifyKind, + message: impl AsRef, + ) -> bool { + let (title, body) = crate::terminal_notify::split_message(message.as_ref()); + self.send_notify_to_foreground_client(kind, title, body.map(str::to_string)) + } + + pub(super) fn handle_notification_show_api( + &mut self, + id: String, + params: api::schema::NotificationShowParams, + ) -> String { + use api::schema::NotificationShowReason; + + let Some(title) = sanitize_notification_text(¶ms.title, 80) else { + return serde_json::to_string(&api::schema::ErrorResponse { + id, + error: api::schema::ErrorBody { + code: "invalid_params".into(), + message: "notification title is empty".into(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()); + }; + + let body = params + .body + .as_deref() + .and_then(|body| sanitize_notification_text(body, 240)); + let has_client_shell = self.clients.values().any(ClientConnection::is_shell_client); + if !has_client_shell { + let reason = if self.app.state.toast_config.delivery == config::ToastDelivery::Off { + NotificationShowReason::Disabled + } else { + NotificationShowReason::NoForegroundClient + }; + return notification_show_result(id, false, reason); + } + if self.app.api_notification_rate_limited(Instant::now()) { + return notification_show_result(id, false, NotificationShowReason::RateLimited); + } + let sound = match params.sound { + api::schema::NotificationShowSound::None => None, + api::schema::NotificationShowSound::Done => { + Some(protocol::SemanticNotificationSound::Done) + } + api::schema::NotificationShowSound::Request => { + Some(protocol::SemanticNotificationSound::Request) + } + }; + let shown = self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title, + body, + sound, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: params.position, + }, + )); + if shown { + self.app.mark_api_notification_shown(Instant::now()); + } + notification_show_result( + id, + shown, + if shown { + NotificationShowReason::Shown + } else { + NotificationShowReason::NoForegroundClient + }, + ) + } + + /// Handles a single internal event with forwarding logic for clipboard, + /// sound, and toast notifications to connected clients. + /// + /// ALL internal events MUST be routed through this method to ensure + /// clipboard/notify forwarding is never bypassed. Do not call + /// `self.app.handle_internal_event()` directly for any internal event + /// in the headless server — use this method instead. + /// + /// Returns true if the event changed visual state (requiring a re-render). + pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { + match &ev { + AppEvent::TerminalBell { pane_id, count } => { + if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { + debug!( + pane = pane_id.raw(), + count, "dropped terminal bell without a foreground client" + ); + } + false + } + AppEvent::ClipboardWrite { content } => { + // Clipboard writes are client-local side effects. Forward them only to + // the foreground client instead of broadcasting to every attached client. + let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); + self.send_to_foreground_client(ServerMessage::Clipboard { data }); + false + } + AppEvent::StateChanged { pane_id, agent, .. } => { + // Capture toast before handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = *agent; + + // Find the previous effective state of this pane before the event + // is processed. Notifications must follow effective state changes, + // not raw fallback reports that may be masked by hook authority. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + // Handle the state change (updates pane state, sets toast on AppState). + // Headless mode disables local sound playback separately from the + // sound policy so reloads can keep server-side notification policy live. + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification to clients when server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::HookStateReported { + pane_id, + agent_label, + .. + } => { + // Hook reports can be stale or no-op after sequence rejection. + // Forward only effective state changes observed after handling. + let toast_before = self.app.state.toast.clone(); + let pane_id_val = *pane_id; + let agent_val = crate::detect::parse_agent_label(agent_label); + + // Capture the previous effective state for this pane. Hook reports + // are already folded into pane.state; raw hook transitions must not + // produce a second notification path. + let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); + + self.sync_foreground_client_state(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + let suppress_completion = pane_updates + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); + for update in pane_updates + .iter() + .filter(|update| update.pane_id == pane_id_val) + { + self.forward_semantic_agent_notification(update); + } + + // Forward sound notification based on the effective transition when + // server-side sound policy allows it. + let is_active_tab = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .is_some_and(|ws| { + ws.find_tab_index_for_pane(pane_id_val) + .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) + }); + + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); + + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && self.app.state.sound.allows(agent_val) + { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + toast_message_from_state_change( + &self.app.state, + &self.app.terminal_runtimes, + pane_id_val, + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + ) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::UpdateReady { + version, + install_command, + } => { + let toast_before = self.app.state.toast.clone(); + let version = version.clone(); + let install_command = install_command.clone(); + + self.app.handle_internal_event(ev); + self.send_to_client_shells(ServerMessage::SemanticNotification( + protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + title: format!("Herdr v{version} available"), + body: Some(crate::update::update_install_instruction(&install_command)), + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + )); + + let toast_msg = + if should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else { + Some(format!( + "v{version} available: {}", + crate::update::update_install_instruction(&install_command) + )) + } + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_flat_toast_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + msg, + ); + } + + true + } + AppEvent::PaneDied { pane_id } => { + let pane_id_val = *pane_id; + let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.panes + .get(pane_id) + .map(|pane| pane.attached_terminal_id.to_string()) + }) + }); + if let Some(update) = self + .app + .state + .publish_pane_process_exit_if_agent(pane_id_val) + { + self.app.emit_pane_state_update(&update); + self.forward_semantic_agent_notification(&update); + self.forward_pane_state_update_notifications_to_clients(&update); + } + + self.app.handle_internal_event(ev); + + if self.app.find_pane(pane_id_val).is_none() { + if let Some(terminal_id) = terminal_id { + self.shutdown_terminal_stream_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } + } + + true + } + _ => self.app.handle_internal_event_with_render_impact(ev), + } + } + + /// Drains internal events, forwarding clipboard, sound, and toast + /// notifications to connected clients instead of processing them locally. + /// + /// The server has no host terminal or audio subsystem, so we: + /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the + /// foreground client only. + /// - Detect when a sound would be played and forward as + /// `ServerMessage::Notify { kind: Sound }` to the foreground client. + /// - Detect when a toast is set on AppState and forward as + /// `ServerMessage::Notify` to the foreground client for terminal/system delivery. + pub(super) fn drain_internal_events_with_forwarding(&mut self) -> bool { + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT) + .1 + } + + pub(super) fn drain_all_internal_events_with_forwarding(&mut self) -> bool { + let mut changed = false; + loop { + let (had_event, batch_changed) = + self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT); + changed |= batch_changed; + if !had_event || self.should_quit.load(Ordering::Acquire) { + break; + } + } + changed + } + + pub(super) fn drain_internal_events_with_forwarding_up_to( + &mut self, + limit: usize, + ) -> (bool, bool) { + let mut had_event = false; + let mut changed = false; + for _ in 0..limit { + let Ok(ev) = self.app.event_rx.try_recv() else { + break; + }; + had_event = true; + changed |= self.handle_internal_event_with_forwarding(ev); + } + (had_event, changed) + } +} diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index 151b26ee..bce53b72 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -1,21 +1,7 @@ use super::{HeadlessServer, RenderImpact}; use crate::api; use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; -use crate::server::clients::{render_targets, ClientConnectionMode, DeferredRender}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RetainedGraphicsOutcome { - Sent, - Deferred, - Fallback, -} - -pub(super) fn frame_pane_graphics(bytes: Vec) -> Vec { - if bytes.is_empty() { - return bytes; - } - [b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat() -} +use crate::server::clients::ClientConnectionMode; impl HeadlessServer { pub(super) fn pane_graphics_runtime_active(&self) -> bool { @@ -76,7 +62,6 @@ impl HeadlessServer { .and_then(crate::app::pane_graphics::Layer::direct_lease) .map(|lease| { ( - slot.host_image_id, lease.path().to_string_lossy().into_owned(), lease.len() as u64, lease.fingerprint(), @@ -84,21 +69,35 @@ impl HeadlessServer { }) }) }); - if let (Some(key), Some((image_id, path, expected_len, transfer_id))) = + if let (Some(key), Some((path, expected_len, transfer_id))) = (direct_key.clone(), direct_frame) { - let command = self.clients.get(&client_id).and_then(|client| { - crate::kitty_graphics::prepare_direct_file( - &self.app.state, - &self.app.pane_graphics, - self.app.state.view.tab_surface(), - client.cell_size, - !internal_changed, - &client.graphics_cache, - &key, - ) + let prepared = self.clients.get(&client_id).and_then(|client| { + matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?; + let layer = self + .app + .pane_graphics + .slots + .get(&key) + .and_then(|slot| slot.layer.as_ref())?; + let asset = crate::kitty_graphics::surface::pane_layer_asset_key( + &self.app, &key, layer, + )?; + let (image_id, control) = + crate::kitty_graphics::surface::direct_upload_control( + &self.client_shell_boot_id, + &asset, + ); + Some(( + crate::kitty_graphics::DirectFileCommand { + leading: Vec::new(), + control, + }, + Some(asset), + image_id, + )) }); - let Some(command) = command else { + let Some((command, surface_asset, image_id)) = prepared else { if self.install_inline_fallback(&key) { if msg.respond_to.send(response).is_err() { self.retire_direct_gate(&key); @@ -123,6 +122,7 @@ impl HeadlessServer { transfer_id, leading: command.leading, control: command.control, + surface_asset, }; let send = Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE) @@ -144,6 +144,7 @@ impl HeadlessServer { if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) { slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { transfer_id, + image_id, client_id, deadline: std::time::Instant::now() + crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT, @@ -267,10 +268,14 @@ impl HeadlessServer { image_id: u32, ) -> bool { if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| { - (slot.host_image_id == image_id && slot.stream_is_active()) + slot.stream_is_active() .then_some(slot.direct_gate.as_mut()) .flatten() - .filter(|gate| gate.client_id == client_id && gate.transfer_id == transfer_id) + .filter(|gate| { + gate.client_id == client_id + && gate.transfer_id == transfer_id + && gate.image_id == image_id + }) }) { gate.written = true; gate.deadline = @@ -297,7 +302,7 @@ impl HeadlessServer { slot.stream_is_active() && gate.client_id == client_id && gate.transfer_id == transfer_id - && slot.host_image_id == image_id + && gate.image_id == image_id && (!success || gate.written) }) .then(|| key.clone()) @@ -312,11 +317,11 @@ impl HeadlessServer { .iter() .any(|workspace| workspace.pane_state(key.0).is_some()); if !pane_is_live { - self.retire_direct_gate(&key); + self.retire_direct_gate_with_client_notice(&key); return false; } if success { - let (gate, host_image_id) = { + let gate = { let slot = self .app .pane_graphics @@ -329,23 +334,8 @@ impl HeadlessServer { if let Some(layer) = slot.layer.as_mut() { layer.mark_resident(client_id); } - ( - slot.direct_gate.take().expect("matched gate"), - slot.host_image_id, - ) + slot.direct_gate.take().expect("matched gate") }; - if let (Some(client), Some(layer)) = ( - self.clients.get_mut(&client_id), - self.app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref()), - ) { - client - .graphics_cache - .trust_pane_layer(&key, host_image_id, layer); - } if gate.respond_to.send(gate.success_response).is_err() { self.retire_direct_gate(&key); return true; @@ -362,9 +352,6 @@ impl HeadlessServer { self.retire_all_direct_graphics(); return true; } - if let Some(client) = self.clients.get_mut(&client_id) { - client.graphics_cache.forget_pane_layer(&key, image_id); - } let gate = self .app .pane_graphics @@ -404,7 +391,7 @@ impl HeadlessServer { *client_id, ServerMessage::GraphicsTransmissionRetired { transfer_id: gate.transfer_id, - image_id: slot.host_image_id, + image_id: gate.image_id, }, ); } @@ -421,8 +408,53 @@ impl HeadlessServer { !expired.is_empty() } + fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) { + if let Some((client_id, transfer_id, image_id)) = self + .app + .pane_graphics + .slots + .get(key) + .and_then(|slot| slot.direct_gate.as_ref()) + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)) + { + self.send_to_client( + client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + }, + ); + } + self.retire_direct_gate(key); + } + + pub(super) fn retain_live_pane_graphics(&mut self) -> bool { + let dead_direct = self + .app + .pane_graphics + .slots + .iter() + .filter(|((pane_id, _), slot)| { + slot.direct_gate.is_some() + && !self + .app + .state + .workspaces + .iter() + .any(|workspace| workspace.pane_state(*pane_id).is_some()) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + let changed = !dead_direct.is_empty(); + for key in dead_direct { + self.retire_direct_gate_with_client_notice(&key); + } + let retained = self.app.pane_graphics.retain_live_panes(&self.app.state); + changed || retained + } + pub(super) fn retire_all_direct_graphics(&mut self) { - let keys = self + let retired = self .app .pane_graphics .slots @@ -432,9 +464,26 @@ impl HeadlessServer { .as_ref() .is_some_and(crate::app::pane_graphics::Layer::terminal_only) }) - .map(|(key, _)| key.clone()) + .map(|(key, slot)| { + let notification = slot + .direct_gate + .as_ref() + .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)); + (key.clone(), notification) + }) .collect::>(); - for key in keys { + for (_, notification) in &retired { + if let Some((client_id, transfer_id, image_id)) = notification { + self.send_to_client( + *client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id: *transfer_id, + image_id: *image_id, + }, + ); + } + } + for (key, _) in retired { self.retire_direct_gate(&key); } } @@ -460,127 +509,4 @@ impl HeadlessServer { self.retire_direct_gate(&key); } } - - pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome { - crate::render_prof::event("retained_graphics.attempt"); - if self.app.full_redraw_pending { - crate::render_prof::event("retained_graphics_fallback.full_redraw_pending"); - return RetainedGraphicsOutcome::Fallback; - } - - let render_targets = render_targets(&self.clients, self.foreground_client_id); - let mut app_view_size = None; - for (_, terminal_size, _, _, mode) in &render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - if app_view_size.is_some_and(|size| size != *terminal_size) { - crate::render_prof::event("retained_graphics_fallback.mixed_app_geometry"); - return RetainedGraphicsOutcome::Fallback; - } - app_view_size = Some(*terminal_size); - } - let mut deferred = false; - let mut prepared = Vec::new(); - - for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets { - if !matches!(mode, ClientConnectionMode::App) { - continue; - } - let Some(client) = self.clients.get_mut(&client_id) else { - crate::render_prof::event("retained_graphics_fallback.client_missing"); - return RetainedGraphicsOutcome::Fallback; - }; - if client.deferred_render() != DeferredRender::None { - deferred = true; - continue; - } - if client.graphics_surface_reset_pending || !cell_size.is_known() { - crate::render_prof::event("retained_graphics_fallback.client_state"); - return RetainedGraphicsOutcome::Fallback; - } - let Some(last_frame) = client.render_state.last_frame() else { - crate::render_prof::event("retained_graphics_fallback.no_last_frame"); - return RetainedGraphicsOutcome::Fallback; - }; - if last_frame.width != cols || last_frame.height != rows { - crate::render_prof::event("retained_graphics_fallback.frame_size_mismatch"); - return RetainedGraphicsOutcome::Fallback; - } - if client.writer.is_none() { - crate::render_prof::event("retained_graphics_fallback.writer_missing"); - return RetainedGraphicsOutcome::Fallback; - } - - let mut next_graphics_cache = client.graphics_cache.clone(); - let encode_started = crate::render_prof::timer(); - let encoded = crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.pane_graphics, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut next_graphics_cache, - ); - crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started); - prepared.push((client_id, encoded, next_graphics_cache)); - } - - let mut broken_clients = Vec::new(); - for (client_id, encoded, next_graphics_cache) in prepared { - let Some(client) = self.clients.get_mut(&client_id) else { - continue; - }; - let serialized = if encoded.bytes.is_empty() { - None - } else { - match Self::frame_server_message_with_max( - &ServerMessage::Graphics { - bytes: frame_pane_graphics(encoded.bytes), - }, - MAX_GRAPHICS_FRAME_SIZE, - ) { - Ok(serialized) => Some(serialized), - Err(_) => { - crate::render_prof::event("retained_graphics_fallback.oversized"); - return RetainedGraphicsOutcome::Fallback; - } - } - }; - let result = match (serialized, client.writer.as_ref()) { - (None, _) => Ok(()), - (Some(bytes), Some(writer)) => writer.render.try_send(bytes), - (Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)), - }; - match result { - Ok(()) => { - client.graphics_cache = next_graphics_cache; - if encoded.incomplete { - client.defer_full_render(); - deferred = true; - } else { - client.clear_deferred_render(); - } - crate::render_prof::event("retained_graphics.sent"); - } - Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_full_render(); - deferred = true; - } - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id) - } - } - } - for client_id in broken_clients { - self.remove_client_and_resize_if_needed(client_id); - } - - if deferred { - RetainedGraphicsOutcome::Deferred - } else { - RetainedGraphicsOutcome::Sent - } - } } diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs new file mode 100644 index 00000000..cab79de2 --- /dev/null +++ b/src/server/headless/render.rs @@ -0,0 +1,606 @@ +use super::*; + +impl HeadlessServer { + fn focused_pane_graphics_demand(&self) -> bool { + self.app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .and_then(crate::workspace::Workspace::focused_pane_id) + .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) + } + + pub(super) fn stream_host_mouse_capture_mode(&mut self) { + let endpoint_shell_mouse = self.app.state.popup_pane.is_some() + || self + .app + .state + .focused_pane_requests_mouse_capture_from(&self.app.terminal_runtimes); + let pixel_mouse_requested = self + .clients + .values() + .any(|client| client.is_shell_client() && client.pixel_mouse); + let shell_sgr_pixels = pixel_mouse_requested + && self.focused_pane_graphics_demand() + && self + .app + .state + .active + .and_then(|ws_idx| { + self.app + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + .and_then(|pane_id| { + self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + ws_idx, + pane_id, + ) + }) + }) + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::ClientShell => Some(( + client_id, + client.shell_mouse_capture || endpoint_shell_mouse, + shell_sgr_pixels && client.pixel_mouse, + )), + ClientConnectionMode::TerminalAttach { terminal_id } => { + let runtime = self.runtime_for_terminal_id_string(terminal_id); + let child_requests_mouse = runtime + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled); + let sgr_pixels = child_requests_mouse + && client.pixel_mouse + && runtime + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); + Some((client_id, child_requests_mouse, sgr_pixels)) + } + ClientConnectionMode::TerminalPending + | ClientConnectionMode::TerminalObserve { .. } => None, + }) + .collect::>(); + + let mut broken_clients = Vec::new(); + for (client_id, enabled, sgr_pixels) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_mouse_capture_active == Some(enabled) + && client.host_sgr_pixels_active == Some(sgr_pixels) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { + enabled, + sgr_pixels, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for client"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during mouse capture update" + ); + broken_clients.push(client_id); + continue; + } + client.host_mouse_capture_active = Some(enabled); + client.host_sgr_pixels_active = Some(sgr_pixels); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn stream_direct_terminal_keyboard_mode(&mut self) { + let shell_report_all = { + let runtime = if self.app.state.popup_pane.is_some() { + self.app.popup_runtime() + } else { + self.app.state.active.and_then(|workspace_index| { + self.app + .state + .focused_runtime_in_workspace(&self.app.terminal_runtimes, workspace_index) + }) + }; + runtime.is_some_and(|runtime| { + let protocol = runtime.keyboard_protocol(); + protocol.reports_all_keys() + || (protocol.reports_event_types() && runtime.modify_other_keys_level() > 0) + }) + }; + let serialized_shell = + Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll { + enabled: shell_report_all, + }); + let mut broken_clients = Vec::new(); + for (&client_id, client) in &mut self.clients { + if !client.is_shell_client() + || client.host_keyboard_report_all_active == Some(shell_report_all) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let Ok(serialized) = serialized_shell.as_ref() else { + warn!("failed to serialize client shell keyboard report-all mode"); + break; + }; + if writer.control.send(serialized.clone()).is_err() { + broken_clients.push(client_id); + continue; + } + client.host_keyboard_report_all_active = Some(shell_report_all); + } + + let requested = self + .clients + .iter() + .filter_map(|(&client_id, client)| { + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return None; + }; + let (flags, modify_other_keys_level) = self + .runtime_for_terminal_id_string(terminal_id) + .map_or((0, 0), |runtime| { + let flags = match runtime.keyboard_protocol() { + crate::input::KeyboardProtocol::Legacy => 0, + crate::input::KeyboardProtocol::Kitty { flags } => flags, + }; + (flags, runtime.modify_other_keys_level()) + }); + Some((client_id, flags, modify_other_keys_level)) + }) + .collect::>(); + + for (client_id, flags, modify_other_keys_level) in requested { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_keyboard_protocol_active == Some((flags, modify_other_keys_level)) { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + let serialized = + match Self::frame_server_message(&ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize direct terminal keyboard mode"); + continue; + } + }; + if writer.control.send(serialized).is_err() { + debug!( + client_id, + "client writer channel closed during direct terminal keyboard update" + ); + broken_clients.push(client_id); + continue; + } + client.host_keyboard_protocol_active = Some((flags, modify_other_keys_level)); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + pub(super) fn has_pending_presentation_work( + &self, + needs_full_render: bool, + needs_graphics_render: bool, + ) -> bool { + needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work() + } + + pub(super) fn sync_immediate_pty_sources(&self) { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + let mut pane_ids = if has_app_target { + self.app.state.app_surface_pane_ids() + } else { + HashSet::new() + }; + if !direct_terminal_targets.is_empty() { + for workspace in &self.app.state.workspaces { + for tab in &workspace.tabs { + pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| { + direct_terminal_targets + .contains(pane.attached_terminal_id.as_str()) + .then_some(pane_id) + })); + } + } + if let Some(popup) = &self.app.state.popup_pane { + if direct_terminal_targets.contains(popup.terminal_id.as_str()) { + pane_ids.insert(popup.pane_id); + } + } + } + self.app.render_dirty.set_immediate_pty_sources(pane_ids); + } + + fn pty_render_targets(&self) -> (bool, HashSet<&str>) { + let mut has_app_target = false; + let mut direct_terminal_targets = HashSet::new(); + for client in self + .clients + .values() + .filter(|client| client.writer.is_some()) + { + match &client.mode { + ClientConnectionMode::ClientShell => has_app_target = true, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + direct_terminal_targets.insert(terminal_id.as_str()); + } + ClientConnectionMode::TerminalPending => {} + } + } + (has_app_target, direct_terminal_targets) + } + + fn pty_source_visible_to_render_targets( + &self, + pane_id: crate::layout::PaneId, + has_app_target: bool, + direct_terminal_targets: &HashSet<&str>, + ) -> bool { + let terminal_id = self.terminal_id_for_pane(pane_id); + (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) + || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) + } + + pub(super) fn pty_sources_visible_to_any_render_target( + &self, + sources: &HashSet, + ) -> bool { + let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); + if !has_app_target && direct_terminal_targets.is_empty() { + return false; + } + + sources.iter().copied().any(|pane_id| { + self.pty_source_visible_to_render_targets( + pane_id, + has_app_target, + &direct_terminal_targets, + ) + }) + } + + fn terminal_id_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalId> { + if let Some(popup) = self + .app + .state + .popup_pane + .as_ref() + .filter(|popup| popup.pane_id == pane_id) + { + return Some(&popup.terminal_id); + } + self.app + .find_pane(pane_id) + .map(|(_, pane)| &pane.attached_terminal_id) + } + + fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return true; + } + let Some(workspace) = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + else { + return false; + }; + let Some(tab) = workspace.active_tab() else { + return false; + }; + if !tab.panes.contains_key(&pane_id) { + return false; + } + !tab.zoomed || tab.layout.focused() == pane_id + } + + pub(super) fn render_and_stream(&mut self) { + let full_started = crate::render_prof::timer(); + let render_targets = render_targets(&self.clients, self.foreground_client_id); + + if render_targets.is_empty() { + let (cols, rows) = self.effective_size; + let area = Rect::new(0, 0, cols, rows); + let resize_panes = self.app.state.view.pane_infos.is_empty(); + if resize_panes { + crate::ui::compute_view_with_runtime_registry( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } else { + crate::ui::compute_view_without_resizing_panes( + &mut self.app.state, + &self.app.terminal_runtimes, + area, + ); + } + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); + debug!( + cols, + rows, resize_panes, "updated geometry with no attached clients" + ); + return; + } + + let shell_snapshot_template = render_targets + .iter() + .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell)) + .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None)); + let mut broken_clients: Vec = Vec::new(); + for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { + let area = Rect::new(0, 0, cols, rows); + let mut shell_projection_revision = 0; + if matches!(mode, ClientConnectionMode::ClientShell) { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(mut candidate) = shell_snapshot_template.clone() else { + continue; + }; + candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings { + self.server_config_diagnostic.clone() + } else { + self.server_config_diagnostic_without_keybindings.clone() + }; + candidate.revision = client.shell_projection_revision; + if client.shell_snapshot.as_ref() != Some(&candidate) { + client.shell_projection_revision = + client.shell_projection_revision.saturating_add(1); + candidate.revision = client.shell_projection_revision; + let message = ServerMessage::ClientShellSnapshot(Box::new(candidate.clone())); + let framed = match Self::frame_server_message(&message) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to frame client shell replacement"); + broken_clients.push(client_id); + continue; + } + }; + let Some(writer) = client.writer.as_ref() else { + broken_clients.push(client_id); + continue; + }; + if writer.control.send(framed).is_err() { + broken_clients.push(client_id); + continue; + } + client.shell_snapshot = Some(candidate); + } + shell_projection_revision = client.shell_projection_revision; + } + let shell_graphics_delivery = self + .clients + .get(&client_id) + .map(|client| client.shell_graphics_delivery.clone()) + .unwrap_or_default(); + let mut surface_parts = None; + let frame = match mode { + ClientConnectionMode::ClientShell => { + let render_started = crate::render_prof::timer(); + let render_cell_size = if cell_size.is_known() { + cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let crate::server::client_shell::RenderedPaneSurface { + frame, + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } = render_client_shell_pane_surface( + &mut self.app, + area, + is_foreground, + render_cell_size, + &shell_graphics_delivery, + client_id, + ); + crate::render_prof::duration_since( + "full_render.render_tab_surface_virtual", + render_started, + ); + surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); + frame + } + ClientConnectionMode::TerminalPending => continue, + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach ended: terminal {terminal_id} not found" + )), + }, + ); + broken_clients.push(client_id); + continue; + }; + let render_started = crate::render_prof::timer(); + let (buffer, cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, area); + crate::render_prof::duration_since( + "full_render.render_terminal_virtual", + render_started, + ); + let hyperlinks_started = crate::render_prof::timer(); + let hyperlinks = runtime.visible_hyperlinks(area); + crate::render_prof::duration_since( + "full_render.visible_hyperlinks", + hyperlinks_started, + ); + let frame_started = crate::render_prof::timer(); + let frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + cursor, + &hyperlinks, + ); + crate::render_prof::duration_since("full_render.frame_build", frame_started); + frame + } + }; + + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + crate::render_prof::event("full_render.writer_missing"); + continue; + }; + let has_graphics = surface_parts + .as_ref() + .is_some_and(|(_, _, _, graphics, _)| { + !graphics.assets.is_empty() + || !graphics.placements.is_empty() + || !graphics.retained_assets.is_empty() + }); + let mut next_shell_graphics_delivery = None; + let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts { + next_shell_graphics_delivery = Some(delivery); + client + .render_state + .prepare_pane_surface(protocol::PaneSurfaceFrame { + boot_id: self.client_shell_boot_id.clone(), + projection_revision: shell_projection_revision, + surface_revision: 0, + frame, + panes, + splits, + popup, + graphics, + }) + } else { + client.render_state.prepare_frame(frame) + }; + let Some(mut prepared) = prepared else { + client.clear_deferred_render(); + crate::render_prof::event("full_render.skip_identical"); + continue; + }; + let max = if has_graphics { + MAX_GRAPHICS_FRAME_SIZE + } else { + crate::protocol::MAX_FRAME_SIZE + }; + let mut shell_assets_deferred = false; + let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { + Ok(frame) => frame, + Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { + warn!( + client_id, + claimed, max, "dropping graphics assets from oversized pane surface" + ); + if !prepared.strip_pane_surface_assets() { + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + next_shell_graphics_delivery = None; + shell_assets_deferred = true; + match Self::frame_server_message(prepared.message()) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to serialize pane surface without assets"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + } + } + Err(protocol::FramingError::Oversized { claimed, max }) => { + warn!( + client_id, + claimed, max, "skipping oversized frame for client" + ); + crate::render_prof::event("full_render.serialize_oversized"); + continue; + } + Err(err) => { + warn!(client_id, err = %err, "failed to serialize frame"); + broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + continue; + } + }; + let shell_graphics_pending = next_shell_graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + match writer.render.try_send(serialized) { + Ok(()) => { + if let Some(delivery) = next_shell_graphics_delivery { + client.shell_graphics_delivery = delivery; + } + client.render_state.commit_sent_frame(prepared); + if shell_graphics_pending || shell_assets_deferred { + client.defer_full_render(); + } else { + client.clear_deferred_render(); + } + crate::render_prof::event("full_render.sent"); + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.defer_full_render(); + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + broken_clients.push(client_id); + } + } + } + + if !broken_clients.is_empty() { + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + + let (cols, rows) = self.effective_size; + // Full-frame recovery is tracked per connection. A slow client must not + // keep responsive peers on the global full-render path while it waits + // for its render slot to drain. + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); + debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); + } +} diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs new file mode 100644 index 00000000..b15c4319 --- /dev/null +++ b/src/server/headless/retained_surface.rs @@ -0,0 +1,613 @@ +use super::*; + +fn rect_fits_frame(rect: protocol::SurfaceRect, frame: &FrameData) -> bool { + rect.x.saturating_add(rect.width) <= frame.width + && rect.y.saturating_add(rect.height) <= frame.height +} + +fn patch_intersects_hyperlinks( + frame: &FrameData, + area: protocol::SurfaceRect, + patch: &crate::pane::TerminalDirtyPatch, +) -> bool { + if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { + return false; + } + let width = usize::from(frame.width); + patch + .rows + .iter() + .filter(|(local_y, _)| *local_y < area.height) + .any(|(local_y, _)| { + let start = usize::from(area.y + *local_y) * width + usize::from(area.x); + let end = start + usize::from(area.width); + end > frame.cells.len() + || frame.cells[start..end] + .iter() + .any(|cell| cell.hyperlink.is_some()) + }) +} + +fn apply_patch_row(frame: &mut FrameData, row: &protocol::PaneSurfacePatchRow) -> Option { + if row.y >= frame.height + || row.x.saturating_add(u16::try_from(row.cells.len()).ok()?) > frame.width + { + return None; + } + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + let end = start + row.cells.len(); + if end > frame.cells.len() { + return None; + } + let changed = frame.cells[start..end] != row.cells; + frame.cells[start..end].clone_from_slice(&row.cells); + Some(changed) +} + +fn apply_rows( + frame: &mut FrameData, + area: protocol::SurfaceRect, + patch: &crate::pane::TerminalDirtyPatch, +) -> Option> { + if !rect_fits_frame(area, frame) { + return None; + } + let mut rows = Vec::new(); + for (local_y, cells) in &patch.rows { + if *local_y >= area.height { + continue; + } + let width = usize::from(area.width); + if cells.len() < width { + return None; + } + let y = area.y + *local_y; + let frame_start = usize::from(y) * usize::from(frame.width) + usize::from(area.x); + let frame_end = frame_start.checked_add(width)?; + let existing = frame.cells.get(frame_start..frame_end)?; + let desired = &cells[..width]; + let mut offset = 0; + while offset < width { + if existing[offset] == desired[offset] { + offset += 1; + continue; + } + let start = offset; + offset += 1; + while offset < width && existing[offset] != desired[offset] { + offset += 1; + } + // Include the following cell so a wide-to-narrow (or + // narrow-to-wide) transition repaints content covered by the old + // grapheme width even when that logical neighbor is unchanged. + let end = offset.saturating_add(1).min(width); + rows.push(protocol::PaneSurfacePatchRow { + x: area.x.checked_add(u16::try_from(start).ok()?)?, + y, + cells: desired[start..end].to_vec(), + }); + offset = end; + } + } + for row in &rows { + apply_patch_row(frame, row)?; + } + Some(rows) +} + +fn retained_scrollbar_patch( + app: &app::App, + frame: &mut FrameData, + pane: &mut protocol::PaneSurfacePane, + alternate_screen_active: bool, + metrics: Option, +) -> Option> { + let next_rect = metrics + .filter(|metrics| metrics.max_offset_from_bottom > 0) + .filter(|_| app.state.pane_scrollbars && !alternate_screen_active) + .and_then(|_| { + let rect = protocol::SurfaceRect { + x: pane.inner_rect.x.checked_add(pane.inner_rect.width)?, + y: pane.inner_rect.y, + width: 1, + height: pane.inner_rect.height, + }; + (rect_fits_frame(rect, frame) + && rect.x >= pane.rect.x + && rect.x < pane.rect.x.saturating_add(pane.rect.width)) + .then_some(rect) + }); + let patch_rect = next_rect.or(pane.scrollbar_rect); + pane.scrollbar_rect = next_rect; + let Some(rect) = patch_rect else { + return Some(Vec::new()); + }; + + let track = Rect::new(0, 0, 1, rect.height); + let mut buffer = ratatui::buffer::Buffer::empty(track); + if let (Some(metrics), Some(_)) = (metrics, next_rect) { + crate::ui::render_pane_scrollbar_buffer( + &mut buffer, + metrics, + track, + &app.state.palette, + pane.focused, + ); + } + let cells = buffer + .content + .iter() + .map(protocol::CellData::from_ratatui_cell) + .collect::>(); + let mut rows = Vec::new(); + for (offset, cell) in cells.into_iter().enumerate() { + let row = protocol::PaneSurfacePatchRow { + x: rect.x, + y: rect.y.checked_add(u16::try_from(offset).ok()?)?, + cells: vec![cell], + }; + if apply_patch_row(frame, &row)? { + rows.push(row); + } + } + Some(rows) +} + +fn retained_cursor( + app: &app::App, + panes: &[protocol::PaneSurfacePane], +) -> Option { + let pane = panes.iter().find(|pane| pane.focused)?; + let (workspace_index, pane_id) = app.parse_pane_id(&pane.pane_id)?; + if !app.state.pane_exposes_host_cursor(workspace_index, pane_id) { + return None; + } + let runtime = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + )?; + if runtime.synchronized_output_active() { + return None; + } + let area = Rect::new( + pane.inner_rect.x, + pane.inner_rect.y, + pane.inner_rect.width, + pane.inner_rect.height, + ); + runtime + .cursor_state(area, true) + .map(|cursor| protocol::CursorState { + x: cursor.x, + y: cursor.y, + visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), + shape: cursor.shape, + }) +} + +struct RetainedRecipient { + client_id: u64, + surface: protocol::PaneSurfaceFrame, +} + +struct CollectedPanePatch { + pane_id: String, + patch: crate::pane::TerminalDirtyPatch, + content_revision: u64, + scroll_metrics: Option, + mouse_reporting: bool, + sgr_pixel_mouse: bool, + alternate_screen_active: bool, +} + +struct RetainedRecipientUpdate { + client_id: u64, + surface: protocol::PaneSurfaceFrame, + patch: protocol::PaneSurfacePatch, +} + +impl HeadlessServer { + /// Applies terminal dirty rows to the committed origin-relative pane surface. + /// Any presentation or geometry uncertainty falls back to the complete renderer. + pub(super) fn render_retained_pane_surface_and_stream( + &mut self, + pty_sources: &HashSet, + ) -> bool { + crate::render_prof::event("retained_surface.attempt"); + let started = crate::render_prof::timer(); + macro_rules! fallback { + ($reason:literal) => {{ + crate::render_prof::event(concat!("retained_surface.fallback.", $reason)); + crate::render_prof::duration_since("retained_surface.total", started); + return false; + }}; + } + macro_rules! success { + ($reason:literal) => {{ + crate::render_prof::event("retained_surface.success"); + crate::render_prof::event(concat!("retained_surface.success.", $reason)); + crate::render_prof::duration_since("retained_surface.total", started); + return true; + }}; + } + + if pty_sources.is_empty() + || self.app.full_redraw_pending + || self.app.state.popup_pane.is_some() + || self.app.state.reveal_hidden_cursor_for_cjk_ime + { + fallback!("unsafe_state"); + } + let targets = render_targets(&self.clients, self.foreground_client_id); + if targets.is_empty() + || targets + .iter() + .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) + { + fallback!("non_shell_target"); + } + + let mut recipients = Vec::with_capacity(targets.len()); + for (client_id, (cols, rows), _, _, _) in &targets { + let Some(client) = self.clients.get(client_id) else { + fallback!("client_missing"); + }; + if client.deferred_render() != DeferredRender::None { + crate::render_prof::event("retained_surface.recipient_deferred"); + continue; + } + let Some(surface) = client.render_state.last_pane_surface() else { + fallback!("no_baseline"); + }; + if surface.boot_id != self.client_shell_boot_id + || surface.projection_revision != client.shell_projection_revision + || surface.frame.width != *cols + || surface.frame.height != *rows + || surface.popup.is_some() + || !surface.graphics.assets.is_empty() + || !surface.graphics.placements.is_empty() + || !surface.graphics.retained_assets.is_empty() + || !surface.frame.graphics.is_empty() + { + fallback!("baseline_mismatch"); + } + recipients.push(RetainedRecipient { + client_id: *client_id, + surface: surface.clone(), + }); + } + if recipients.is_empty() { + success!("all_recipients_deferred"); + } + + let mut collected = Vec::with_capacity(pty_sources.len()); + for source in pty_sources { + let mut public_pane_id = None; + let mut width = 0u16; + let mut height = 0u16; + for recipient in &recipients { + let Some(pane) = recipient.surface.panes.iter().find(|pane| { + self.app + .parse_pane_id(&pane.pane_id) + .is_some_and(|(_, pane_id)| pane_id == *source) + }) else { + fallback!("source_not_visible"); + }; + public_pane_id.get_or_insert_with(|| pane.pane_id.clone()); + width = width.max(pane.inner_rect.width); + height = height.max(pane.inner_rect.height); + } + let Some(public_pane_id) = public_pane_id else { + fallback!("pane_missing"); + }; + let Some((workspace_index, pane_id)) = self.app.parse_pane_id(&public_pane_id) else { + fallback!("pane_missing"); + }; + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + workspace_index, + pane_id, + ) else { + fallback!("runtime_missing"); + }; + let revision_before = runtime.content_seq(); + if !revision_before.is_multiple_of(2) { + fallback!("unstable_content"); + } + let patch = match runtime.collect_dirty_patch(width, height) { + crate::pane::TerminalDirtyPatchOutcome::Clean => { + crate::render_prof::event("retained_surface.pane_clean"); + crate::pane::TerminalDirtyPatch { rows: Vec::new() } + } + crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => patch, + crate::pane::TerminalDirtyPatchOutcome::Fallback => { + fallback!("terminal_patch"); + } + }; + let revision = runtime.content_seq(); + if revision != revision_before || !revision.is_multiple_of(2) { + fallback!("content_changed"); + } + collected.push(CollectedPanePatch { + pane_id: public_pane_id, + patch, + content_revision: revision, + scroll_metrics: runtime.scroll_metrics(), + mouse_reporting: runtime.mouse_reporting_enabled(), + sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), + alternate_screen_active: runtime.alternate_screen_active(), + }); + } + + let mut updates = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let client_id = recipient.client_id; + let mut surface = recipient.surface; + let projection_revision = surface.projection_revision; + let base_surface_revision = surface.surface_revision; + let mut changed_panes = Vec::with_capacity(collected.len()); + let mut patch_rows = Vec::new(); + let mut metadata_changed = false; + for collected_pane in &collected { + let Some(pane) = surface + .panes + .iter_mut() + .find(|pane| pane.pane_id == collected_pane.pane_id) + else { + fallback!("pane_missing"); + }; + // Alternate-screen transitions change whether the pane reserves + // a scrollbar gutter. Recompute layout and resize the runtime + // through the complete renderer before retaining further rows. + if pane.alternate_screen_active != collected_pane.alternate_screen_active { + fallback!("alternate_screen_geometry"); + } + if patch_intersects_hyperlinks( + &surface.frame, + pane.inner_rect, + &collected_pane.patch, + ) { + fallback!("hyperlink"); + } + let previous_pane = pane.clone(); + let Some(rows) = + apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch) + else { + fallback!("invalid_patch"); + }; + patch_rows.extend(rows); + let Some(scrollbar_rows) = retained_scrollbar_patch( + &self.app, + &mut surface.frame, + pane, + collected_pane.alternate_screen_active, + collected_pane.scroll_metrics, + ) else { + fallback!("scrollbar_patch"); + }; + patch_rows.extend(scrollbar_rows); + pane.content_revision = collected_pane.content_revision; + pane.mouse_reporting = collected_pane.mouse_reporting; + pane.sgr_pixel_mouse = collected_pane.sgr_pixel_mouse; + pane.alternate_screen_active = collected_pane.alternate_screen_active; + pane.scroll = collected_pane.scroll_metrics.map(|metrics| { + protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + } + }); + metadata_changed |= *pane != previous_pane; + changed_panes.push(pane.clone()); + } + + let cursor = retained_cursor(&self.app, &surface.panes); + let cursor_changed = cursor != surface.frame.cursor; + surface.frame.cursor = cursor.clone(); + if patch_rows.is_empty() && !cursor_changed && !metadata_changed { + continue; + } + let patch = protocol::PaneSurfacePatch { + boot_id: self.client_shell_boot_id.clone(), + projection_revision, + base_surface_revision, + surface_revision: 0, + rows: patch_rows, + panes: changed_panes, + cursor, + }; + updates.push(RetainedRecipientUpdate { + client_id, + surface, + patch, + }); + } + if updates.is_empty() { + success!("unchanged"); + } + + let mut sent = 0u64; + let mut deferred = 0u64; + let mut disconnected = Vec::new(); + for update in updates { + let RetainedRecipientUpdate { + client_id, + surface, + patch, + } = update; + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + client.defer_full_render(); + deferred += 1; + continue; + }; + let Some(prepared) = client + .render_state + .prepare_pane_surface_patch(patch, surface) + else { + client.defer_full_render(); + deferred += 1; + continue; + }; + let serialized = match Self::frame_server_message(prepared.message()) { + Ok(serialized) => serialized, + Err(error) => { + warn!( + client_id, + %error, + "failed to serialize retained pane surface patch" + ); + client.defer_full_render(); + deferred += 1; + continue; + } + }; + crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64); + match writer.render.try_send(serialized) { + Ok(()) => { + client.clear_deferred_render(); + client.render_state.commit_sent_frame(prepared); + sent += 1; + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.defer_full_render(); + deferred += 1; + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + disconnected.push(client_id); + } + } + } + for client_id in disconnected { + self.remove_client_and_resize_if_needed(client_id); + } + crate::render_prof::counter("retained_surface.recipients.sent", sent); + crate::render_prof::counter("retained_surface.recipients.deferred", deferred); + if sent > 0 { + success!("sent"); + } + success!("recovery_queued"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(symbol: &str) -> protocol::CellData { + protocol::CellData { + symbol: symbol.to_owned(), + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: None, + } + } + + #[test] + fn retained_rows_send_only_changed_cell_spans() { + let mut frame = FrameData { + width: 6, + height: 2, + cells: vec![cell(" "); 12], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell(" "), cell("x"), cell("y"), cell(" ")])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 1, + y: 1, + width: 4, + height: 1, + }, + &patch, + ) + .expect("valid patch"); + + assert_eq!( + rows, + vec![protocol::PaneSurfacePatchRow { + x: 2, + y: 1, + cells: vec![cell("x"), cell("y"), cell(" ")], + }] + ); + assert_eq!(frame.cells[8], cell("x")); + assert_eq!(frame.cells[9], cell("y")); + } + + #[test] + fn retained_rows_include_the_cell_after_a_width_transition() { + let mut frame = FrameData { + width: 3, + height: 1, + cells: vec![cell("界"), cell("z"), cell("q")], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell("x"), cell("z"), cell("q")])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 0, + y: 0, + width: 3, + height: 1, + }, + &patch, + ) + .expect("valid patch"); + + assert_eq!( + rows, + vec![protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![cell("x"), cell("z")], + }] + ); + } + + #[test] + fn retained_rows_omit_unchanged_full_dirty_rows() { + let mut frame = FrameData { + width: 4, + height: 2, + cells: vec![cell(" "); 8], + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let patch = crate::pane::TerminalDirtyPatch { + rows: vec![(0, vec![cell(" "); 4]), (1, vec![cell(" "); 4])], + }; + + let rows = apply_rows( + &mut frame, + protocol::SurfaceRect { + x: 0, + y: 0, + width: 4, + height: 2, + }, + &patch, + ) + .expect("valid patch"); + + assert!(rows.is_empty()); + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs new file mode 100644 index 00000000..102f2fc6 --- /dev/null +++ b/src/server/headless/tests/mod.rs @@ -0,0 +1,5025 @@ +use super::*; + +#[path = "pane_graphics.rs"] +mod pane_graphics_tests; + +fn test_headless_server() -> HeadlessServer { + test_headless_server_with_event_hub(api::EventHub::default()) +} + +fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServer { + let config = crate::config::Config::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = crate::app::App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); + + let dir = std::env::temp_dir().join(format!( + "hh-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::create_dir_all(&dir); + let socket_path = dir.join("client.sock"); + let _ = fs::remove_file(&socket_path); + let listener = bind_local_listener(&socket_path).expect("bind test listener"); + let client_socket_identity = + socket_file_identity(&socket_path).expect("test listener socket identity"); + #[cfg(unix)] + listener + .set_nonblocking(ListenerNonblockingMode::Accept) + .expect("set listener nonblocking"); + let (server_event_tx, server_event_rx) = mpsc::channel(64); + let should_quit = Arc::new(AtomicBool::new(false)); + #[cfg(windows)] + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + let server_keybindings = app_keybindings(&app); + let headless_size = app.state.headless_size; + + HeadlessServer { + app, + #[cfg(unix)] + api_tx: None, + api_server: None, + #[cfg(unix)] + client_listener: listener, + client_socket_path: socket_path, + client_socket_identity, + clients: HashMap::new(), + #[cfg(unix)] + next_client_id: 1, + foreground_client_id: None, + client_shell_boot_id: "test-boot".into(), + sent_window_title: None, + api_window_title: None, + server_keybindings, + server_config_diagnostic: None, + server_config_diagnostic_without_keybindings: None, + terminal_attach_owners: HashMap::new(), + pending_alt_screen_reads: Vec::new(), + deferred_alt_screen_reads: Vec::new(), + next_activity_stamp: 1, + headless_size, + effective_size: headless_size, + shutting_down: false, + handoff_in_progress: false, + #[cfg(unix)] + pending_handoff_repaint_nudge: false, + should_quit, + server_event_rx, + server_event_tx, + } +} + +fn shutdown_test_runtimes(server: &mut HeadlessServer) { + for (_, runtime) in server.app.terminal_runtimes.drain() { + runtime.shutdown(); + } +} + +fn read_server_message(bytes: Vec) -> ServerMessage { + let mut cursor = std::io::Cursor::new(bytes); + protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message") +} + +fn frame_text(frame: &FrameData) -> String { + frame + .cells + .chunks(usize::from(frame.width)) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::() + }) + .collect::>() + .join("\n") +} + +fn read_server_shutdown_reason(bytes: Vec) -> Option { + match read_server_message(bytes) { + ServerMessage::ServerShutdown { reason } => reason, + other => panic!("expected shutdown, got {other:?}"), + } +} + +#[test] +fn completed_handoff_disables_only_old_server_session_persistence() { + let mut server = test_headless_server(); + server.app.policy = crate::app::AppPolicy::PRODUCTION; + + server.finish_live_handoff_shutdown(); + + assert!(!server.app.policy.persist_session); + assert!(server.app.policy.restore_session); + assert!(server.app.policy.persist_plugin_registry); + assert!(server.app.policy.background_updates); +} + +#[test] +fn default_headless_size_is_effective_without_clients() { + let server = test_headless_server(); + + assert_eq!( + server.headless_size, + ( + crate::config::DEFAULT_HEADLESS_COLS, + crate::config::DEFAULT_HEADLESS_ROWS + ) + ); + assert_eq!(server.effective_size, server.headless_size); +} + +#[tokio::test] +async fn headless_api_reads_latest_title_without_spinner_event_flooding() { + let event_hub = api::EventHub::default(); + let mut server = test_headless_server_with_event_hub(event_hub.clone()); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.sidebar_agents.rows = vec![vec![ + crate::config::AgentSidebarToken::TerminalTitleStripped, + ]]; + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .detected_agent = Some(crate::detect::Agent::Claude); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07"); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.render_dirty.request_terminal_title(pane_id); + + let first = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); + assert_eq!(first.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); + server.app.render_dirty.request_terminal_title(pane_id); + let second = headless_pane_list(&mut server).pop().unwrap(); + assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); + assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); + assert_eq!(pane_updated_events(&event_hub), 1); +} + +fn headless_pane_list(server: &mut HeadlessServer) -> Vec { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "list-titles".into(), + method: api::schema::Method::PaneList(api::schema::PaneListParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + let response: api::schema::SuccessResponse = + serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); + let api::schema::ResponseResult::PaneList { panes } = response.result else { + panic!("expected pane list"); + }; + panes +} + +fn pane_updated_events(event_hub: &api::EventHub) -> usize { + event_hub + .events_after(0) + .iter() + .filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated) + .count() +} + +#[test] +fn server_stop_interrupts_server_event_backlog() { + let mut server = test_headless_server(); + for client_id in 1..=64 { + server + .server_event_tx + .try_send(ServerEvent::ClientDisconnected { client_id }) + .unwrap(); + } + + server.should_quit.store(true, Ordering::Release); + + assert!(!server.drain_server_events()); + assert!(server.server_event_rx.try_recv().is_ok()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn headless_api_request_drains_all_pending_internal_events_before_reading_state() { + let mut server = test_headless_server(); + for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT { + server + .app + .event_tx + .try_send(AppEvent::UpdateReady { + version: format!("4.0.{i}"), + install_command: "herdr install".into(), + }) + .unwrap(); + } + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "headless_stop_after_events".into(), + method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT); + assert_eq!( + server.app.state.update_available.as_deref(), + Some(expected_version.as_str()) + ); + assert!(server.app.event_rx.try_recv().is_err()); +} + +fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver>) { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + drain_window_titles(&control_rx); + (server, control_rx) +} + +/// The test client writer drains its queue on a background thread, so +/// reading a pushed message needs a timeout rather than `try_recv`. +fn next_window_title(control_rx: &std::sync::mpsc::Receiver>) -> Option> { + let deadline = Instant::now() + Duration::from_secs(5); + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + let Ok(bytes) = control_rx.recv_timeout(remaining) else { + return None; + }; + if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { + return Some(title); + } + } + None +} + +fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver>) { + while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} +} + +fn no_window_title(control_rx: &std::sync::mpsc::Receiver>) -> bool { + while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { + if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { + return false; + } + } + true +} + +#[test] +fn window_title_waits_for_a_foreground_client_to_exist() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.configure_window_title("{workspace}"); + + // The server renders before the first client attaches. Nothing was + // delivered, so nothing may be recorded as delivered either. + server.sync_window_title(); + assert_eq!(server.sent_window_title, None); + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + server.sync_window_title(); + + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // ClientConnected assigns the foreground client directly rather than + // going through promote_client_to_foreground, so the cache must notice + // the new client on its own. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn configured_window_title_reaches_the_foreground_client_once_per_change() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}/{tab}"); + + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/1".to_string())) + ); + + // An unchanged title must not re-emit an OSC on every render. + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/build".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{terminal_title}"); + server.app.state.ensure_test_terminals(); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0] + .terminal_id(pane_id) + .expect("terminal") + .clone(); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert_eq!( + next_window_title(&control_rx), + Some(Some("building".to_string())) + ); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert!(no_window_title(&control_rx)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { + let (mut server, _control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + // A detached client keeps its entry but loses its writer, so nothing + // reaches a terminal even though the targeted send reports success. + if let Some(client) = server.clients.get_mut(&1) { + client.writer = None; + } + server.sync_window_title(); + assert!(server.sent_window_title.is_none()); + + // Attaching again has to deliver the title rather than skip it as sent. + let (client_tx, control_rx, _render_rx) = test_client_writer(); + if let Some(client) = server.clients.get_mut(&1) { + client.writer = Some(client_tx); + } + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn empty_window_title_config_leaves_the_outer_title_alone() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.sync_window_title(); + + assert!(no_window_title(&control_rx)); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn api_window_title_wins_until_it_is_cleared() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.app.state.workspaces[0].custom_name = Some("ops".into()); + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + // Clearing hands the title back to ui.window_title, not to "herdr". + server.handle_client_window_title_api("clear".into(), None); + assert_eq!( + next_window_title(&control_rx), + Some(Some("ops".to_string())) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.handle_client_window_title_api("clear".into(), None); + assert_eq!(next_window_title(&control_rx), Some(None)); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn a_newly_promoted_client_gets_the_window_title_again() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // A second terminal starts on whatever its shell or ssh left behind. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); +} + +fn test_client_writer() -> ( + ClientWriter, + std::sync::mpsc::Receiver>, + std::sync::mpsc::Receiver>, +) { + let (control_tx, control_rx) = std::sync::mpsc::channel(); + let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1); + ( + ClientWriter::test_channel(control_tx, render_tx), + control_rx, + render_rx, + ) +} + +#[tokio::test] +async fn client_shell_attach_seeds_workspace() { + let mut server = test_headless_server(); + server.app.state.workspaces.clear(); + server.app.state.active = None; + server.app.state.mode = crate::app::Mode::Navigate; + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 6, + 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, + writer, + }) + ); + + assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); + assert_eq!(server.app.state.workspaces.len(), 1); + assert_eq!(server.app.state.active, Some(0)); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_endpoint_request_uses_the_selected_connection() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client_id = 41; + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + 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, + writer, + }) + ); + let _initial_snapshot = control_rx.recv().expect("initial shell snapshot"); + let boot_id = server.client_shell_boot_id.clone(); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "client-shell:1".into(), + method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default(),), + }), + }) + ); + assert!(server.clients[&client_id].shell_endpoint_command_in_flight); + + let response_ready = server + .server_event_rx + .recv() + .await + .expect("endpoint response ready"); + assert!(!server.handle_server_event(response_ready)); + assert!(!server.clients[&client_id].shell_endpoint_command_in_flight); + + match read_server_message(control_rx.recv().expect("endpoint response")) { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id: response_boot_id, + request_id, + final_chunk, + data, + } => { + assert_eq!(response_boot_id, boot_id); + assert_eq!(request_id, "client-shell:1"); + assert!(final_chunk); + let response = serde_json::from_slice::(&data) + .expect("success response"); + assert_eq!(response.id, "client-shell:1"); + assert!(matches!( + response.result, + api::schema::ResponseResult::IntegrationList { .. } + )); + } + other => panic!("expected client shell endpoint response, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_receives_metadata_then_shell_free_pane_surface() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("shell-only-label"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 23, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016hCLIENT_SHELL_LIVE", + ), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + server.app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + scroll: 0, + preview: true, + }); + server.server_config_diagnostic_without_keybindings = Some("endpoint config warning".into()); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 7, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + match read_server_message(control_rx.recv().expect("shell snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => { + assert_eq!(snapshot.workspaces.len(), 1); + assert_eq!(snapshot.workspaces[0].label, "shell-only-label"); + assert_eq!( + snapshot.config_diagnostic.as_deref(), + Some("endpoint config warning") + ); + assert_eq!( + snapshot.product_announcement.as_ref().map(|announcement| ( + announcement.version.as_str(), + announcement.id.as_str(), + announcement.preview, + )), + Some(("0.8.2", "client-shell", true)) + ); + } + other => panic!("expected client shell snapshot, got {other:?}"), + } + + server.render_and_stream(); + let initial_surface = match read_server_message(render_rx.recv().expect("pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!((surface.frame.width, surface.frame.height), (80, 23)); + let text = frame_text(&surface.frame); + assert!(text.contains("CLIENT_SHELL_LIVE"), "surface: {text:?}"); + assert!(!text.contains("shell-only-label"), "surface: {text:?}"); + assert_eq!(surface.panes.len(), 1); + assert_eq!(surface.panes[0].rect.x, 0); + assert_eq!(surface.panes[0].rect.y, 0); + assert!(surface.panes[0].sgr_pixel_mouse); + assert_eq!( + surface.panes[0].pixel_width, + u32::from(surface.panes[0].inner_rect.width) * 10 + ); + assert_eq!( + surface.panes[0].pixel_height, + u32::from(surface.panes[0].inner_rect.height) * 20 + ); + surface + } + other => panic!("expected pane surface, got {other:?}"), + }; + + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(b"\rPATCHED"); + let sources = std::collections::HashSet::from([pane_id]); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + match read_server_message(render_rx.recv().expect("pane surface patch")) { + ServerMessage::PaneSurfacePatch(patch) => { + assert_eq!( + patch.base_surface_revision, + initial_surface.surface_revision + ); + assert_eq!(patch.surface_revision, initial_surface.surface_revision + 1); + assert_eq!(patch.panes.len(), 1); + assert!(!patch.rows.is_empty()); + assert!(patch + .rows + .iter() + .flat_map(|row| &row.cells) + .any(|cell| cell.symbol == "P")); + } + other => panic!("expected pane surface patch, got {other:?}"), + } + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(b"\x1b[?1003l\x1b[?1006l\x1b[?1016l"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + match read_server_message(render_rx.recv().expect("metadata-only pane surface patch")) { + ServerMessage::PaneSurfacePatch(patch) => { + assert_eq!(patch.panes.len(), 1); + assert!(!patch.panes[0].mouse_reporting); + assert!(!patch.panes[0].sgr_pixel_mouse); + } + other => panic!("expected metadata-only pane surface patch, got {other:?}"), + } + let retained = server.clients[&7] + .render_state + .last_pane_surface() + .expect("committed retained surface") + .clone(); + server + .clients + .get_mut(&7) + .unwrap() + .render_state + .request_repaint(); + server.render_and_stream(); + let full = match read_server_message(render_rx.recv().expect("full comparison surface")) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("expected full comparison surface, got {other:?}"), + }; + assert!(full.surface_revision > retained.surface_revision); + assert_eq!(retained.frame, full.frame); + assert_eq!(retained.panes, full.panes); + assert_eq!(retained.splits, full.splits); + shutdown_test_runtimes(&mut server); +} + +fn install_shared_view_test_runtime(server: &mut HeadlessServer) -> crate::layout::PaneId { + let mut workspace = crate::workspace::Workspace::test_new("shared-view"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"BASE"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + pane_id +} + +fn connect_test_shell( + server: &mut HeadlessServer, + client_id: u64, + surface_cols: u16, + surface_rows: u16, +) -> ( + std::sync::mpsc::Receiver>, + std::sync::mpsc::Receiver>, +) { + let (writer, control, render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols, + surface_rows, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + (control, render) +} + +fn connect_matching_test_shell( + server: &mut HeadlessServer, + client_id: u64, +) -> ( + std::sync::mpsc::Receiver>, + std::sync::mpsc::Receiver>, +) { + connect_test_shell(server, client_id, 80, 23) +} + +fn write_shared_test_pane( + server: &mut HeadlessServer, + pane_id: crate::layout::PaneId, + bytes: &[u8], +) { + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("pane runtime") + .test_process_pty_bytes(bytes); +} + +fn recv_pane_surface( + receiver: &std::sync::mpsc::Receiver>, + context: &str, +) -> crate::protocol::PaneSurfaceFrame { + match read_server_message( + receiver + .recv() + .unwrap_or_else(|error| panic!("{context}: {error}")), + ) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("{context}: expected pane surface, got {other:?}"), + } +} + +fn recv_pane_surface_patch( + receiver: &std::sync::mpsc::Receiver>, + context: &str, +) -> crate::protocol::PaneSurfacePatch { + match read_server_message( + receiver + .recv() + .unwrap_or_else(|error| panic!("{context}: {error}")), + ) { + ServerMessage::PaneSurfacePatch(patch) => patch, + other => panic!("{context}: expected pane surface patch, got {other:?}"), + } +} + +#[tokio::test] +async fn different_size_shells_receive_geometry_specific_patches_from_one_dirty_collection() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (large_control, large_render) = connect_test_shell(&mut server, 7, 80, 23); + let (small_control, small_render) = connect_test_shell(&mut server, 8, 68, 17); + let _ = large_control.recv().expect("large snapshot"); + let _ = small_control.recv().expect("small snapshot"); + server.render_and_stream(); + let large_initial = recv_pane_surface(&large_render, "large initial surface"); + let small_initial = recv_pane_surface(&small_render, "small initial surface"); + assert_eq!( + (large_initial.frame.width, large_initial.frame.height), + (80, 23) + ); + assert_eq!( + (small_initial.frame.width, small_initial.frame.height), + (68, 17) + ); + assert_ne!( + large_initial.panes[0].inner_rect, + small_initial.panes[0].inner_rect + ); + + write_shared_test_pane(&mut server, pane_id, b"\rMIXED"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + + let large_patch = recv_pane_surface_patch(&large_render, "large retained patch"); + let small_patch = recv_pane_surface_patch(&small_render, "small retained patch"); + assert_eq!( + large_patch.base_surface_revision, + large_initial.surface_revision + ); + assert_eq!( + small_patch.base_surface_revision, + small_initial.surface_revision + ); + assert!(large_patch.rows.iter().all(|row| { + row.x + .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX)) + <= large_initial.frame.width + && row.y < large_initial.frame.height + })); + assert!(small_patch.rows.iter().all(|row| { + row.x + .saturating_add(u16::try_from(row.cells.len()).unwrap_or(u16::MAX)) + <= small_initial.frame.width + && row.y < small_initial.frame.height + })); + assert_eq!(large_patch.rows, small_patch.rows); + assert_ne!( + large_patch.panes[0].inner_rect, + small_patch.panes[0].inner_rect + ); + assert!(frame_text( + &server.clients[&7] + .render_state + .last_pane_surface() + .expect("large retained surface") + .frame + ) + .contains("MIXED")); + assert!(frame_text( + &server.clients[&8] + .render_state + .last_pane_surface() + .expect("small retained surface") + .frame + ) + .contains("MIXED")); + + write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049hALT"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let large_alt = recv_pane_surface(&large_render, "large alternate-screen surface"); + let small_alt = recv_pane_surface(&small_render, "small alternate-screen surface"); + assert!(large_alt.panes[0].alternate_screen_active); + assert!(small_alt.panes[0].alternate_screen_active); + assert_eq!( + large_alt.panes[0].inner_rect.width, + large_initial.panes[0].inner_rect.width + 1 + ); + assert_eq!( + small_alt.panes[0].inner_rect.width, + small_initial.panes[0].inner_rect.width + 1 + ); + + write_shared_test_pane(&mut server, pane_id, b"\x1b[?1049l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let large_main = recv_pane_surface(&large_render, "large restored main-screen surface"); + let small_main = recv_pane_surface(&small_render, "small restored main-screen surface"); + assert!(!large_main.panes[0].alternate_screen_active); + assert!(!small_main.panes[0].alternate_screen_active); + assert_eq!( + large_main.panes[0].inner_rect, + large_initial.panes[0].inner_rect + ); + assert_eq!( + small_main.panes[0].inner_rect, + small_initial.panes[0].inner_rect + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn backpressured_shell_does_not_disable_retained_patches_for_responsive_peer() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7); + let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8); + let _ = responsive_control.recv().expect("responsive snapshot"); + let _ = slow_control.recv().expect("slow snapshot"); + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive initial surface"); + let _ = slow_render.recv().expect("slow initial surface"); + + let sources = HashSet::from([pane_id]); + write_shared_test_pane(&mut server, pane_id, b"\rONE"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive first patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + write_shared_test_pane(&mut server, pane_id, b"\rTWO"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive second patch")), + ServerMessage::PaneSurfacePatch(_) + )); + assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full); + + write_shared_test_pane(&mut server, pane_id, b"\rTHREE"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive third patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + assert!(matches!( + read_server_message(slow_render.recv().expect("slow queued first patch")), + ServerMessage::PaneSurfacePatch(_) + )); + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 })); + server.render_and_stream(); + assert!(matches!( + read_server_message(slow_render.recv().expect("slow full recovery surface")), + ServerMessage::PaneSurface(_) + )); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn full_render_backpressure_does_not_disable_responsive_peer_patches() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (responsive_control, responsive_render) = connect_matching_test_shell(&mut server, 7); + let (slow_control, slow_render) = connect_matching_test_shell(&mut server, 8); + let _ = responsive_control.recv().expect("responsive snapshot"); + let _ = slow_control.recv().expect("slow snapshot"); + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive initial surface"); + // Keep the slow client's initial surface queued, then force another full + // replacement for both clients. + server.clients.get_mut(&7).unwrap().request_repaint(); + server.clients.get_mut(&8).unwrap().request_repaint(); + server.app.full_redraw_pending = true; + server.render_and_stream(); + let _ = responsive_render + .recv() + .expect("responsive full replacement"); + assert_eq!(server.clients[&8].deferred_render(), DeferredRender::Full); + assert!(!server.app.full_redraw_pending); + + write_shared_test_pane(&mut server, pane_id, b"\rPATCH"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(matches!( + read_server_message(responsive_render.recv().expect("responsive retained patch")), + ServerMessage::PaneSurfacePatch(_) + )); + + let _ = slow_render.recv().expect("slow queued initial surface"); + assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 8 })); + server.render_and_stream(); + assert!(matches!( + read_server_message(slow_render.recv().expect("slow full recovery surface")), + ServerMessage::PaneSurface(_) + )); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_config_diagnostics_follow_keybinding_ownership() { + let mut server = test_headless_server(); + server.server_config_diagnostic = Some("server keybinding warning\ntheme warning".into()); + server.server_config_diagnostic_without_keybindings = Some("theme warning".into()); + + let (local_writer, local_control, _local_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 13, + 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, + writer: local_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(local_snapshot) = + read_server_message(local_control.recv().expect("local shell snapshot")) + else { + panic!("expected local shell snapshot"); + }; + assert_eq!( + local_snapshot.config_diagnostic.as_deref(), + Some("theme warning") + ); + + let (endpoint_writer, endpoint_control, _endpoint_render) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 14, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: false, + writer: endpoint_writer, + }) + ); + let ServerMessage::ClientShellSnapshot(endpoint_snapshot) = + read_server_message(endpoint_control.recv().expect("endpoint shell snapshot")) + else { + panic!("expected endpoint shell snapshot"); + }; + assert_eq!( + endpoint_snapshot.config_diagnostic.as_deref(), + Some("server keybinding warning\ntheme warning") + ); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_replaces_projection_and_focuses_stable_ids() { + let mut server = test_headless_server(); + let first = crate::workspace::Workspace::test_new("first"); + let second = crate::workspace::Workspace::test_new("second"); + server.app.state.workspaces = vec![first, second]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_id = server.app.session_snapshot().workspaces[1] + .workspace_id + .clone(); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 9, + 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, + writer, + }) + ); + let initial_revision = match read_server_message(control_rx.recv().expect("initial snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot.revision, + other => panic!("expected initial shell snapshot, got {other:?}"), + }; + + let _ = server.app.handle_api_request(crate::api::schema::Request { + id: "test.client.shell.workspace.focus".into(), + method: crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: second_id.clone(), + }), + }); + assert_eq!(server.app.state.active, Some(1)); + server.render_and_stream(); + + let replacement = match read_server_message(control_rx.recv().expect("replacement snapshot")) { + ServerMessage::ClientShellSnapshot(snapshot) => snapshot, + other => panic!("expected replacement shell snapshot, got {other:?}"), + }; + assert!(replacement.revision > initial_revision); + assert_eq!( + replacement.focused_workspace_id.as_deref(), + Some(second_id.as_str()) + ); + match read_server_message(render_rx.recv().expect("replacement pane surface")) { + ServerMessage::PaneSurface(surface) => { + assert_eq!(surface.projection_revision, replacement.revision); + } + other => panic!("expected replacement pane surface, got {other:?}"), + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_input_targets_runtime_without_server_shell_classification() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1000h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![ + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: crossterm::event::KeyModifiers::ALT.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + }, + crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down( + crate::protocol::ClientMouseButton::Left, + ), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }, + ], + }) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane interrupt"), + Bytes::from_static(&[0x03]) + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane alt key"), + Bytes::from_static(b"\x1bx") + ); + assert_eq!( + input_rx.try_recv().expect("targeted pane mouse click"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + assert_eq!(server.foreground_client_id, Some(11)); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + assert!(server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Pane(pane_id.clone()), + "/tmp/client-image.png".into(), + )); + assert_eq!( + input_rx.try_recv().expect("targeted clipboard image path"), + Bytes::from_static(b"/tmp/client-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + "/tmp/wrong-target.png".into(), + )); + assert!(!server.paste_client_clipboard_image_path( + 11, + crate::protocol::ClientClipboardImageTarget::Popup("missing-popup".into()), + "/tmp/wrong-target.png".into(), + )); + assert!(input_rx.try_recv().is_err()); + + let (workspace_index, runtime_pane_id) = server + .app + .parse_pane_id(&pane_id) + .expect("runtime pane target"); + let runtime = server + .app + .state + .runtime_for_pane_in_workspace( + &server.app.terminal_runtimes, + workspace_index, + runtime_pane_id, + ) + .expect("focused runtime"); + assert_eq!(runtime.current_size(), (24, 79)); + assert!(input_rx.try_recv().is_err(), "legacy release emitted bytes"); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_hidden_pane_rejects_presses_but_accepts_releases() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("hidden-input"); + let hidden_tab = workspace.test_add_tab(Some("hidden")); + let hidden_pane = workspace.tabs[hidden_tab].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>3u", + 4, + ); + workspace.insert_test_runtime(hidden_pane, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let pane_id = server.app.public_pane_id(0, hidden_pane).unwrap(); + server.clients.insert( + 11, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(input_rx.try_recv().is_err()); + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, None); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_streams_and_targets_popup_terminal_content() { + let mut server = test_headless_server(); + let mut pane_input = install_focused_test_runtime(&mut server, b"base-pane"); + let (popup_runtime, mut popup_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 12, + 0, + b"POPUP_SHELL_LIVE\x1b_Ga=T,f=32,t=d,i=9,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + 4, + ); + let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + + let (writer, control_rx, render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 12, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + writer, + }) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("shell snapshot")), + ServerMessage::ClientShellSnapshot(_) + )); + + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup surface")) + else { + panic!("expected pane surface"); + }; + let popup = surface.popup.as_deref().expect("popup terminal surface"); + assert_eq!(popup.terminal_id, popup_terminal_id.as_str()); + assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE")); + assert_eq!((popup.frame.width, popup.frame.height), (37, 9)); + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.placements.len(), 1); + assert!(matches!( + surface.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Popup { .. }, + image_id: 9, + } + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 12, + pane_id: server.app.session_snapshot().focused_pane_id.unwrap(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "must-not-leak".into(), + )], + }) + ); + assert!(pane_input.try_recv().is_err()); + + assert!(server.handle_server_event(ServerEvent::ClientShellResize { + client_id: 12, + surface_cols: 60, + surface_rows: 15, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .expect("popup runtime") + .current_size(), + (5, 27) + ); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: popup_terminal_id.to_string(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "typed".into() + )], + }) + ); + assert_eq!( + popup_input.try_recv().expect("popup input"), + Bytes::from_static(b"typed") + ); + assert!(server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup(popup_terminal_id.to_string()), + "/tmp/popup-image.png".into(), + )); + assert_eq!( + popup_input.try_recv().expect("popup clipboard image path"), + Bytes::from_static(b"/tmp/popup-image.png") + ); + assert!(!server.paste_client_clipboard_image_path( + 12, + crate::protocol::ClientClipboardImageTarget::Popup("stale-popup".into()), + "/tmp/wrong-popup.png".into(), + )); + assert!(popup_input.try_recv().is_err()); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 12, + terminal_id: "stale-popup".into(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "wrong".into() + )], + }) + ); + assert!(popup_input.try_recv().is_err()); + + assert!(server.app.close_popup_pane()); + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("popup close surface")) + else { + panic!("expected pane surface after popup close"); + }; + assert!(surface.popup.is_none()); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("popup-release-scroll"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 2, + 10_000, + b"one\r\ntwo\r\nthree\r\n\x1b[>3u", + 4, + ); + runtime.scroll_up(1); + assert!(runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b""); + server.app.install_test_popup_runtime(popup_runtime); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: Some(0x2d), + }], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_text_input_renders_only_when_resetting_scrollback() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("scrolled-input"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 2, + 10_000, + b"one\r\ntwo\r\nthree\r\n", + 4, + ); + runtime.scroll_up(1); + assert!(runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id.clone(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "x".to_owned(), + )], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert_eq!( + input_rx.try_recv().expect("text must reach the PTY"), + Bytes::from_static(b"x") + ); + assert_eq!( + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .and_then(|runtime| runtime.scroll_metrics()) + .map(|metrics| metrics.offset_from_bottom), + Some(0) + ); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "y".to_owned(), + )], + }); + assert_eq!(render_impact, RenderImpact::None); + assert_eq!( + input_rx.try_recv().expect("second text must reach the PTY"), + Bytes::from_static(b"y") + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_mouse_motion_delivers_without_render_when_foreground() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 0, + }], + }); + + assert_eq!(render_impact, RenderImpact::None); + assert!( + input_rx.try_recv().is_ok(), + "motion must still reach the PTY" + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_mouse_motion_promotes_and_requests_render() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + + let render_impact = + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 0, + }], + }); + + assert_eq!(render_impact, RenderImpact::Full); + assert_eq!(server.foreground_client_id, Some(11)); + assert!( + input_rx.try_recv().is_ok(), + "motion must still reach the PTY" + ); + shutdown_test_runtimes(&mut server); +} + +fn install_focused_test_runtime( + server: &mut HeadlessServer, + terminal_bytes: &[u8], +) -> tokio::sync::mpsc::Receiver { + let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + terminal_bytes, + 4, + ); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + input_rx +} + +fn retained_test_server( + initial_screen: &[u8], +) -> ( + HeadlessServer, + std::sync::mpsc::Receiver>, + crate::layout::PaneId, +) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + + (server, client_rx, pane_id) +} + +#[test] +fn server_keybinding_filter_keeps_whole_config_failures() { + assert!(!config::is_keybinding_config_diagnostic( + "config parse error: invalid value at `keys.new_tab = @`; using defaults" + )); + assert!(!config::is_keybinding_config_diagnostic( + "config read error: permission denied at keys.toml; using defaults" + )); + assert!(config::is_keybinding_config_diagnostic( + "unsafe direct keybinding: keys.close_pane would intercept typing" + )); +} + +#[test] +fn client_shell_host_theme_follows_foreground_client() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + + let dark = protocol::ClientHostColor { + r: 20, + g: 30, + b: 40, + }; + let blue = protocol::ClientHostColor { + r: 10, + g: 20, + b: 200, + }; + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: dark, + }, + }) + ); + assert!( + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::PaletteColors(vec![(4, blue)]), + }) + ); + server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 1, + update: protocol::ClientHostThemeUpdate::Appearance(protocol::ClientHostAppearance::Dark), + }); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + assert_eq!( + server.app.state.host_terminal_theme.palette[4], + Some(blue.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Dark) + ); + assert!(server.app.state.host_terminal_appearance_explicit); + + let light = protocol::ClientHostColor { + r: 240, + g: 230, + b: 220, + }; + assert!( + !server.handle_server_event(ServerEvent::ClientShellHostTheme { + client_id: 2, + update: protocol::ClientHostThemeUpdate::DefaultColor { + kind: protocol::ClientHostDefaultColorKind::Background, + color: light, + }, + }) + ); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(dark.into()) + ); + + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + assert_eq!( + server.app.state.host_terminal_theme.background, + Some(light.into()) + ); + assert_eq!( + server.app.state.host_terminal_appearance, + Some(crate::terminal_theme::HostAppearance::Light) + ); + assert!(!server.app.state.host_terminal_appearance_explicit); +} + +#[test] +fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() { + let mut server = test_headless_server(); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: true, + writer, + })); + assert!(!server.clients[&7].pixel_mouse); + assert_eq!( + server.clients[&7].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + + let (writer, _control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 8, + cols: 80, + rows: 24, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + writer, + })); + assert!(!server.clients[&8].pixel_mouse); + assert_eq!( + server.clients[&8].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + } + ); +} + +#[test] +fn terminal_attach_rejects_missing_terminal_and_removes_client() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!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, + })); + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: "term_missing".to_owned(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some("terminal attach failed: terminal term_missing not found".to_owned()) + ); +} + +fn with_terminal_session_test_server( + test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + let public_pane_id = format!("{}:p1", workspace.id); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + + test(&mut server, terminal_id, terminal_id_string, public_pane_id); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { + let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); +} + +fn connect_pending_terminal_client_with_control_rx( + server: &mut HeadlessServer, + client_id: u64, +) -> std::sync::mpsc::Receiver> { + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!(matches!( + server.clients.get(&client_id).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalPending) + )); + control_rx +} + +#[test] +fn explicit_agent_history_read_requires_idle_on_alternate_screen() { + with_terminal_session_test_server( + |server, terminal_id, _terminal_id_string, public_pane_id| { + let terminal = server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("terminal"); + terminal.detected_agent = Some(crate::detect::Agent::Claude); + terminal.state = crate::detect::AgentState::Working; + server.app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"\x1b[?1049hworking", + ), + ); + let request = api::schema::Request { + id: "read".into(), + method: api::schema::Method::AgentRead(api::schema::AgentReadParams { + target: public_pane_id.clone(), + source: api::schema::ReadSource::Recent, + lines: Some(200), + format: api::schema::ReadFormat::Text, + strip_ansi: true, + }), + }; + + assert_eq!( + server.agent_read_not_idle_error(&request), + Some(api::schema::ErrorBody { + code: "agent_not_idle".into(), + message: format!( + "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" + ), + }) + ); + + let mut default_request = request.clone(); + let api::schema::Method::AgentRead(params) = &mut default_request.method else { + unreachable!(); + }; + params.lines = None; + assert_eq!(server.agent_read_not_idle_error(&default_request), None); + + let mut visible_request = request; + let api::schema::Method::AgentRead(params) = &mut visible_request.method else { + unreachable!(); + }; + params.source = api::schema::ReadSource::Visible; + assert_eq!(server.agent_read_not_idle_error(&visible_request), None); + }, + ); +} + +#[test] +fn terminal_attach_disconnect_restores_client_shell_pane_size() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); + let terminal_id_string = terminal_id.to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.terminal_runtimes.insert( + terminal_id.clone(), + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + let expected_shell_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + connect_pending_terminal_client(&mut server, 2); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 2, + terminal_id: terminal_id_string, + takeover: false, + }) + ); + assert_eq!(server.foreground_client_id, Some(1)); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + (30, 100) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + expected_shell_size + ); + + drop(server); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_observe_allows_multiple_clients_without_attach_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let initial_size = server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(); + + for client_id in [7, 8] { + connect_pending_terminal_client(server, client_id); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id, + target: terminal_id_string.clone(), + }) + ); + } + + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .current_size(), + initial_size + ); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn direct_terminal_observer_keeps_hidden_pty_source_renderable_with_client_shell() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + let hidden_pane = workspace.tabs[0].root_pane; + let terminal_id = workspace + .terminal_id(background_pane) + .expect("background terminal id") + .to_string(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.ensure_test_terminals(); + + let (shell_writer, _shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + + let (observer_writer, _observer_control_rx, _observer_render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalObserve { terminal_id }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(observer_writer), + ), + ); + + assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + server.sync_immediate_pty_sources(); + assert!(server.app.render_dirty.request_pty(background_pane)); + assert!(server.has_pending_presentation_work(false, false)); + assert!(server.app.render_dirty.request_pty(hidden_pane)); +} + +#[test] +fn terminal_observe_resolves_public_pane_id() { + with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: public_pane_id, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id: observed }) + if observed == &terminal_id.to_string() + )); + }); +} + +#[test] +fn terminal_control_resolves_public_pane_id_and_takes_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, public_pane_id| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: public_pane_id, + takeover: false, + }) + ); + + assert!(matches!( + server.clients.get(&7).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalAttach { terminal_id: attached }) + if attached == &terminal_id_string + )); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_control_rejects_attach_during_alt_screen_read() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.pending_alt_screen_reads.push( + crate::server::alt_screen_read::PendingAltScreenRead::start( + terminal_id, + "read".into(), + respond_to, + "fallback".into(), + api::schema::PaneReadResult { + pane_id: "w1:p1".into(), + workspace_id: "w1".into(), + tab_id: "w1:t1".into(), + source: api::schema::ReadSource::Recent, + format: api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + 120, + false, + crate::terminal::ScreenSnapshot { + cols: 80, + rows: Vec::new(), + }, + 0, + Instant::now(), + ), + ); + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some(format!( + "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" + )) + ); + }); +} + +#[test] +fn terminal_control_rejects_second_controller_without_takeover() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.clients.contains_key(&7)); + assert!(!server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + }); +} + +#[test] +fn terminal_control_takeover_replaces_existing_controller() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 8, + target: terminal_id_string.clone(), + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.clients.contains_key(&8)); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&8) + ); + }); +} + +#[test] +fn terminal_observe_can_coexist_with_terminal_control() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + connect_pending_terminal_client(server, 8); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 8, + target: terminal_id_string.clone(), + }) + ); + + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(matches!( + server.clients.get(&8).map(|client| &client.mode), + Some(ClientConnectionMode::TerminalObserve { terminal_id }) + if terminal_id == &terminal_id_string + )); + assert_eq!( + terminal_stream_client_ids(&server.clients, &terminal_id_string).len(), + 2 + ); + }); +} + +#[test] +fn terminal_control_detach_sends_shutdown_before_removal() { + with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + + assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some("detached".to_owned())); + }); +} + +#[test] +fn terminal_observe_rejects_later_attach_upgrade() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string, + takeover: true, + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn terminal_attach_rejects_later_observe_and_clears_ownership() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + connect_pending_terminal_client(server, 7); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id_string.clone(), + takeover: false, + }) + ); + assert_eq!( + server.terminal_attach_owners.get(&terminal_id_string), + Some(&7) + ); + assert!(server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + + assert!( + !server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id: 7, + target: terminal_id_string.clone(), + }) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(server.terminal_attach_owners.is_empty()); + assert!(!server + .app + .state + .direct_attach_resize_locks + .contains(&terminal_id)); + }); +} + +#[test] +fn unchanged_git_refresh_does_not_request_headless_render() { + let mut server = test_headless_server(); + server.app.git_refresh_in_flight = true; + let mut workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + workspace.cached_auto_label = "cached".into(); + workspace.cached_git_status_key = cwd.clone(); + workspace.cached_git_branch = None; + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "cached".into(), + branch: None, + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(!changed); + assert!(!server.app.git_refresh_in_flight); +} + +#[test] +fn changed_git_refresh_requests_headless_render() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("one"); + let workspace_id = workspace.id.clone(); + let cwd = workspace.identity_cwd.clone(); + server.app.state.workspaces.push(workspace); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed { + results: vec![crate::workspace::WorkspaceGitStatus { + workspace_id, + resolved_identity_cwd: cwd.clone(), + status_cache_key: cwd, + demand: crate::workspace::GitStatusRefreshDemand::ALL, + auto_label: "one".into(), + branch: Some("changed".into()), + ahead_behind: None, + space: None, + }], + cache_updates: Vec::new(), + }); + + assert!(changed); +} + +#[test] +fn terminal_attach_client_exits_when_attached_pane_dies() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("attached"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .to_string(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!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, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id.clone(), + takeover: false, + }) + ); + assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); +} + +#[test] +fn terminal_attach_scroll_moves_attached_runtime_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Up, + 3, + None, + None, + 0, + ) + .expect("scroll up"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 3); + + apply_terminal_attach_scroll( + &runtime, + AttachScrollSource::Wheel, + AttachScrollDirection::Down, + 2, + None, + None, + 0, + ) + .expect("scroll down"); + let metrics = runtime.scroll_metrics().expect("scroll metrics"); + assert_eq!(metrics.offset_from_bottom, 1); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("pixel mouse input"); + assert_eq!( + input_rx.try_recv().expect("encoded pixel mouse"), + Bytes::from_static(b"\x1b[<35;21;22M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_pixel_mouse_falls_back_to_canonical_cell_position() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 21, + y: 22, + column: 2, + row: 1, + }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("cell mouse fallback"); + assert_eq!( + input_rx.try_recv().expect("encoded cell mouse"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn client_pane_wheel_input_accumulates_scrollback_offset() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + let scroll = |kind| crate::protocol::ClientPaneInputEvent::Mouse { + kind, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }; + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("first scroll up"); + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollUp)], + ) + .expect("second scroll up"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 6 + ); + + apply_client_pane_input_events( + &runtime, + &[scroll(crate::protocol::ClientMouseKind::ScrollDown)], + ) + .expect("scroll down"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + + runtime.test_process_pty_bytes(b"\x1b[?1003h\x1b[?1006h"); + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("reported mouse motion"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 3 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse motion"), + Bytes::from_static(b"\x1b[<35;3;2M") + ); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("mouse button"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("reported mouse button"), + Bytes::from_static(b"\x1b[<0;3;2M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +#[test] +fn terminal_attach_input_resets_scrolled_viewport() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + + runtime.scroll_up(4); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + + apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded input"), + Bytes::from("x") + ); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn with_terminal_attach_runtime( + initial_bytes: &[u8], + initial_scroll: usize, + test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver), +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let mut bytes = initial_bytes.to_vec(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, 5, 4096, &bytes, 4, + ); + if initial_scroll > 0 { + runtime.scroll_up(initial_scroll); + } + + test(&runtime, &mut input_rx); + + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + +fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) { + apply_terminal_attach_scroll( + runtime, + AttachScrollSource::PageKey { + input: b"\x1b[5~".to_vec(), + }, + AttachScrollDirection::Up, + 4, + None, + None, + 0, + ) + .expect("page key"); +} + +fn client_page_key( + code: crate::protocol::ClientKeyCode, + modifiers: crossterm::event::KeyModifiers, + kind: crate::protocol::ClientKeyKind, +) -> crate::protocol::ClientPaneInputEvent { + crate::protocol::ClientPaneInputEvent::Key { + code, + modifiers: modifiers.bits(), + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: None, + tracks_release: true, + physical_key_id: None, + } +} + +#[test] +fn client_plain_page_keys_scroll_shell_transcript_by_pane_height() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageUp"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Release, + )], + ) + .expect("pane PageUp release"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 5 + ); + + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageDown, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("pane PageDown"); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert!(input_rx.try_recv().is_err(), "page keys reached the shell"); + }); +} + +#[test] +fn client_page_keys_forward_when_modified_or_owned_by_application() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::CONTROL, + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("modified pane PageUp"); + assert!( + input_rx.try_recv().is_ok(), + "modified PageUp was not forwarded" + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); + + with_terminal_attach_runtime(b"\x1b[?1h", 0, |runtime, input_rx| { + apply_client_pane_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("application PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded application PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn client_popup_plain_page_key_remains_popup_input() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_client_popup_input_events( + runtime, + &[client_page_key( + crate::protocol::ClientKeyCode::PageUp, + crossterm::event::KeyModifiers::empty(), + crate::protocol::ClientKeyKind::Press, + )], + ) + .expect("popup PageUp"); + assert_eq!( + input_rx.try_recv().expect("forwarded popup PageUp"), + Bytes::from_static(b"\x1b[5~") + ); + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + }); +} + +#[test] +fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"line one\nline two") + ); + }); +} + +#[test] +fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { + with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_plain_terminal() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_when_application_cursor() { + with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { + with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 4 + ); + assert!(input_rx.try_recv().is_err()); + }); +} + +#[test] +fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { + with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { + apply_terminal_attach_page_up(runtime); + + assert_eq!( + runtime + .scroll_metrics() + .expect("scroll metrics") + .offset_from_bottom, + 0 + ); + assert_eq!( + input_rx.try_recv().expect("forwarded page key"), + Bytes::from_static(b"\x1b[5~") + ); + }); +} + +#[test] +fn headless_scheduled_tasks_expire_agent_metadata() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("metadata"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookStateReported { + pane_id, + source: "custom:pi".into(), + agent_label: "pi".into(), + state: crate::detect::AgentState::Working, + message: None, + seq: None, + session_ref: None, + }) + ); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported { + pane_id, + source: "user:pi-display".into(), + agent_label: Some("pi".into()), + applies_to_source: Some("custom:pi".into()), + title: Some("short lived".into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + seq: None, + // Expiry is advanced with the captured deadline below; keep the + // pre-expiry assertion independent of wall-clock scheduling. + ttl: Some(Duration::from_secs(60)), + }) + ); + + let deadline = server + .app + .agent_metadata_deadline + .expect("metadata deadline"); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .clone(); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title() + .as_deref(), + Some("short lived") + ); + + assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); + + assert_eq!(server.app.agent_metadata_deadline, None); + assert_eq!( + server + .app + .state + .terminals + .get(&terminal_id) + .expect("terminal") + .effective_title(), + None + ); + assert!(server + .app + .event_hub + .events_after(0) + .iter() + .any(|(_, event)| { + event.event == crate::api::schema::EventKind::PaneAgentStatusChanged + && matches!( + &event.data, + crate::api::schema::EventData::PaneAgentStatusChanged { + title, + .. + } if title.is_none() + ) + })); +} + +#[test] +fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { + let mut server = test_headless_server(); + let now = Instant::now(); + server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert_eq!(server.app.next_agent_manifest_update_check, None); +} + +#[cfg(unix)] +#[tokio::test] +async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + server.render_and_stream(); + assert_ne!(server.app.state.view.terminal_area, Rect::default()); + + let now = Instant::now(); + assert!(!server.handle_scheduled_tasks_headless(now, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + let deadline = server + .app + .pending_agent_resume_deadline + .expect("clientless resume should wait briefly for a host theme"); + + assert!(server.handle_scheduled_tasks_headless(deadline, false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_some()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_none()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn terminal_attach_resize_uses_known_cell_geometry_without_pixel_mouse() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ); + client.mode = ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }; + server.clients.insert(1, client); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + Some((800, 480)) + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize { + width_px: 8, + height_px: 16, + } + ); + assert!(!server.clients[&1].pixel_mouse); + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_eq!( + server + .runtime_for_terminal_id_string(&terminal_id) + .unwrap() + .pixel_size(), + None + ); + assert_eq!( + server.clients[&1].cell_size, + crate::kitty_graphics::HostCellSize::default() + ); + assert!(!server.clients[&1].pixel_mouse); + }); +} + +#[test] +fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() { + let mut server = test_headless_server(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + server.foreground_client_id = Some(1); + server.resize_shared_runtime_to_effective_size(); + let shell_size = server.effective_size; + + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 2, + cols: 200, + rows: 60, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: false, + })); + + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.effective_size, shell_size); + assert_eq!(server.clients[&2].terminal_size, (200, 60)); +} + +#[test] +fn client_shell_streams_focused_pane_report_all_demand() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.app.state.active = Some(0); + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("focused runtime") + .test_process_pty_bytes(b"\x1b[>15u"); + + server.stream_direct_terminal_keyboard_mode(); + + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("shell keyboard mode message") + ), + ServerMessage::ClientShellKeyboardReportAll { enabled: true } + )); + }); +} + +#[tokio::test] +async fn client_shell_release_cleanup_does_not_promote_and_survives_disconnect() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>3u"); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + for client_id in [1, 2] { + server.clients.insert( + client_id, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + None, + ), + ); + } + let key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: (kind == crate::protocol::ClientKeyKind::Press).then(|| "x".to_owned()), + tracks_release: true, + physical_key_id: Some(0x2d), + }; + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded press").is_empty()); + assert!(server.promote_client_to_foreground(2)); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: pane_id.clone(), + events: vec![key(crate::protocol::ClientKeyKind::Release)], + }) + ); + assert!(!input_rx.recv().await.expect("encoded release").is_empty()); + assert_eq!(server.foreground_client_id, Some(2)); + + assert!( + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id, + events: vec![key(crate::protocol::ClientKeyKind::Press)], + }) + ); + assert!(!input_rx + .recv() + .await + .expect("second encoded press") + .is_empty()); + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 1 })); + assert!(!input_rx + .recv() + .await + .expect("disconnect synthesized release") + .is_empty()); + shutdown_test_runtimes(&mut server); +} + +#[test] +fn client_shell_mouse_capture_combines_local_preference_with_endpoint_demand() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("initial mouse mode")), + ServerMessage::MouseCapture { + enabled: false, + sgr_pixels: false + } + )); + assert!( + server.handle_server_event(ServerEvent::ClientShellMouseCapture { + client_id: 1, + enabled: true, + }) + ); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv().expect("preferred mouse mode")), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); +} + +#[test] +fn client_shell_focus_promotes_and_reaches_reporting_pane() { + with_terminal_session_test_server(|server, terminal_id, _other_terminal_id, _pane_id| { + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + server.app.state.active = Some(0); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (30, 99) + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: true, + })); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("focused runtime") + .current_size(), + (24, 79) + ); + assert_eq!( + input_rx.try_recv().expect("focus gained input"), + Bytes::from_static(b"\x1b[I") + ); + + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 1, + focused: false, + })); + assert_eq!(server.app.state.outer_terminal_focus, Some(false)); + assert_eq!( + input_rx.try_recv().expect("focus lost input"), + Bytes::from_static(b"\x1b[O") + ); + }); +} + +#[test] +fn direct_terminal_streams_child_keyboard_and_mouse_modes() { + with_terminal_session_test_server(|server, _other_terminal_id, terminal_id, _pane_id| { + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + Some(client_tx), + ), + ); + server + .clients + .get_mut(&1) + .expect("direct attach client") + .pixel_mouse = true; + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>15u\x1b[?1000h"); + + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("keyboard mode message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 15, + modify_other_keys_level: 0 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[3u\x1b[>4;1m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-one keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 1 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[>4;2m"); + server.stream_direct_terminal_keyboard_mode(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("modifyOtherKeys mode-two keyboard message") + ), + ServerMessage::DirectTerminalKeyboardProtocol { + flags: 3, + modify_other_keys_level: 2 + } + )); + + server + .runtime_for_terminal_id_string(&terminal_id) + .expect("attached runtime") + .test_process_pty_bytes(b"\x1b[ {} + other => panic!("expected ReloadSoundConfig, got {other:?}"), + } + assert!(!server.app.state.request_client_config_reload); +} + +#[test] +fn terminal_bell_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 3, + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground terminal bell message"), + ) { + ServerMessage::TerminalBell { count } => assert_eq!(count, 3), + other => panic!("expected terminal bell message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive terminal bells" + ); + + server.foreground_client_id = None; + server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 1, + }); + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "bells without a foreground client must not be retained" + ); +} + +#[test] +fn clipboard_write_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground clipboard message"), + ) { + ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="), + other => panic!("expected clipboard message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive clipboard writes" + ); +} + +#[test] +fn clipboard_write_without_foreground_client_does_not_change_visual_state() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); +} + +#[test] +fn clipboard_write_failed_foreground_send_removes_client_without_visual_change() { + let mut server = test_headless_server(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + drop(foreground_control_rx); + foreground_tx.test_close(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(1); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + }); + + assert!(!changed); + assert!( + !server.clients.contains_key(&1), + "failed targeted send should remove the broken foreground client" + ); +} + +#[test] +fn semantic_notifications_broadcast_only_to_client_shells() { + let mut server = test_headless_server(); + let (shell_one_tx, shell_one_control, _shell_one_frames) = test_client_writer(); + let (shell_two_tx, shell_two_control, _shell_two_frames) = test_client_writer(); + let (terminal_tx, terminal_control, _terminal_frames) = test_client_writer(); + for (client_id, writer) in [(1, shell_one_tx), (2, shell_two_tx)] { + server.clients.insert( + client_id, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + } + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalPending, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::TerminalAnsi, + Some(terminal_tx), + ), + ); + let event = protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "hello".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }; + assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone()))); + for receiver in [shell_one_control, shell_two_control] { + assert_eq!( + read_server_message( + receiver + .recv_timeout(Duration::from_millis(100)) + .expect("semantic notification") + ), + ServerMessage::SemanticNotification(event.clone()) + ); + } + assert!(terminal_control + .recv_timeout(Duration::from_millis(50)) + .is_err()); +} + +#[test] +fn notification_show_uses_client_shell_policy_independent_of_server_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = config::ToastDelivery::Off; + let (shell_tx, shell_control, _shell_frames) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(shell_tx), + ), + ); + let response = server.handle_notification_show_api( + "notify-shell".into(), + api::schema::NotificationShowParams { + title: "plugin title".into(), + body: Some("plugin body".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Done, + }, + ); + let response: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + response.result, + api::schema::ResponseResult::NotificationShow { shown: true, .. } + )); + assert_eq!( + read_server_message( + shell_control + .recv_timeout(Duration::from_millis(100)) + .expect("semantic plugin notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::Custom, + title: "plugin title".into(), + body: Some("plugin body".into()), + sound: Some(protocol::SemanticNotificationSound::Done), + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: Some(crate::config::ToastHerdrPosition::TopLeft), + }) + ); +} + +#[test] +fn client_local_notifications_target_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!(server.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: "pi finished".to_string(), + body: Some("workspace 1".to_string()), + })); + + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground toast message"), + ) { + ServerMessage::Notify { + kind, + message, + body, + } => { + assert_eq!(kind, protocol::NotifyKind::Toast); + assert_eq!(message, "pi finished"); + assert_eq!(body.as_deref(), Some("workspace 1")); + } + other => panic!("expected toast notify, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive client-local notifications" + ); +} + +#[test] +fn oversized_paste_rejection_notifies_only_the_sending_client() { + let mut server = test_headless_server(); + let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer(); + let (foreground_writer, foreground_control_rx, _foreground_render_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(sender_writer), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 2, + RenderEncoding::SemanticFrame, + Some(foreground_writer), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 1, + size: 5_000_012, + max: 1_048_576, + }) + ); + + match read_server_message( + sender_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("sending client rejection notification"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 5000012 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + let (shell_writer, shell_control_rx, _shell_render_rx) = test_client_writer(); + server.clients.insert( + 3, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + 3, + RenderEncoding::SemanticFrame, + Some(shell_writer), + ), + ); + assert!( + !server.handle_server_event(ServerEvent::ClientPasteRejected { + client_id: 3, + size: 7_000_000, + max: 1_048_576, + }) + ); + match read_server_message( + shell_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("client shell rejection error"), + ) { + ServerMessage::ClientShellError { message } => assert_eq!( + message, + "Paste rejected: Input message is 7000000 bytes; Herdr's limit is 1048576 bytes" + ), + other => panic!("expected client shell paste error, got {other:?}"), + } + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "foreground client must not receive another client's rejection" + ); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!(server.clients.len(), 3); + assert!(server.app.state.toast.is_none()); +} + +#[test] +fn update_notification_reaches_client_shell_independent_of_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification") + ), + ServerMessage::SemanticNotification(protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::UpdateInstalled, + .. + }) + )); +} + +#[test] +fn update_notification_is_semantic_for_system_delivery() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady { + version: "9.9.9".to_string(), + install_command: "herdr update".into(), + }); + + assert!(changed); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic update notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!( + notification.kind, + protocol::SemanticNotificationKind::UpdateInstalled + ); + assert_eq!(notification.title, "Herdr v9.9.9 available"); + assert_eq!( + notification.body.as_deref(), + Some("detach, run `herdr update`, then follow its restart guidance") + ); + } + other => panic!("expected semantic update notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_forwards_one_semantic_client_notification() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: Some("api workspace".into()), + position: Some(crate::config::ToastHerdrPosition::TopLeft), + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Request) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_preserves_colon_in_forwarded_title() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build: failed".into(), + body: Some("api workspace".into()), + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build: failed"); + assert_eq!(notification.body.as_deref(), Some("api workspace")); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn notification_show_api_validates_empty_title_before_disabled_delivery() { + let mut server = test_headless_server(); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "\n\t".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(parsed.error.code, "invalid_params"); + assert_eq!(parsed.error.message, "notification title is empty"); +} + +#[test] +fn notification_show_api_reports_no_foreground_client() { + let mut server = test_headless_server(); + server.foreground_client_id = None; + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow(api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Request, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: false, + reason: api::schema::NotificationShowReason::NoForegroundClient, + } + ); +} + +#[test] +fn notification_show_api_includes_sound_in_semantic_event() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "notify".into(), + method: api::schema::Method::NotificationShow( + api::schema::NotificationShowParams { + title: "build failed".into(), + body: None, + position: None, + sound: api::schema::NotificationShowSound::Done, + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + parsed.result, + api::schema::ResponseResult::NotificationShow { + shown: true, + reason: api::schema::NotificationShowReason::Shown, + } + ); + match read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("semantic api notification"), + ) { + ServerMessage::SemanticNotification(notification) => { + assert_eq!(notification.title, "build failed"); + assert_eq!( + notification.sound, + Some(protocol::SemanticNotificationSound::Done) + ); + } + other => panic!("expected semantic api notification, got {other:?}"), + } +} + +#[test] +fn startup_idle_does_not_forward_completion() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("active"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + server.app.state.toast_config.delay_seconds = 0; + server.app.state.sound.enabled = true; + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }) + ); + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + while client_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "startup readiness should not forward a completion notification" + ); +} + +#[test] +fn stale_api_agent_report_does_not_forward_done_sound() { + let mut server = test_headless_server(); + let background = crate::workspace::Workspace::test_new("background"); + let pane_id = background.tabs[0].root_pane; + let public_pane_id = format!("{}:p1", background.id); + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + std::env::current_dir() + .unwrap() + .join("headless-pi-session.jsonl") + .display() + .to_string(), + ) + .unwrap(), + }); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_hook_authority( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(20), + ); + server.app.state.active = Some(1); + server.app.state.selected = 1; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "stale".into(), + method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(19), + agent_session_id: None, + agent_session_path: None, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert!(changed); + assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); + assert_eq!( + server.app.state.terminals.get(&terminal_id).unwrap().state, + crate::detect::AgentState::Working + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "stale idle report must not forward a done sound" + ); +} + +/// Verify that calls to the app's internal-event methods only occur inside +/// `handle_internal_event_with_forwarding`. This ensures the forwarding +/// bypass cannot be reintroduced. +#[test] +fn no_handle_internal_event_bypass_in_module() { + let source = include_str!("../../headless.rs"); + + // Find all lines containing handle_internal_event + let mut bypass_lines: Vec = Vec::new(); + let mut inside_forwarding_method = false; + let mut forwarding_method_brace_depth = 0u32; + + for (i, line) in source.lines().enumerate() { + let line_num = i + 1; + + // Track when we're inside handle_internal_event_with_forwarding + if line.contains("fn handle_internal_event_with_forwarding") { + inside_forwarding_method = true; + forwarding_method_brace_depth = 0; + } + + if inside_forwarding_method { + // Count braces to track when we exit the method + for ch in line.chars() { + match ch { + '{' => forwarding_method_brace_depth += 1, + '}' => { + forwarding_method_brace_depth = + forwarding_method_brace_depth.saturating_sub(1); + if forwarding_method_brace_depth == 0 { + inside_forwarding_method = false; + } + } + _ => {} + } + } + } else if (line.contains("self.app.handle_internal_event(") + || line.contains("self.app.handle_internal_event_with_render_impact(")) + && !line.trim().starts_with("///") + && !line.contains("contains(") + { + // Internal-event call outside the forwarding method. + bypass_lines.push(format!("line {}: {}", line_num, line.trim())); + } + } + + assert!( + bypass_lines.is_empty(), + "Found direct calls to self.app.handle_internal_event outside \ + handle_internal_event_with_forwarding (bypass risk):\n {}", + bypass_lines.join("\n ") + ); +} diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index bd6e3cd7..ecdbc2b7 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -5,61 +5,170 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver>, timeout: Durati } #[tokio::test] -async fn cold_redraw_advances_one_bounded_layer_after_each_send() { - let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw"); - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { +async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; - const LAYERS: usize = 8; - for index in 0..LAYERS { - set_named_graphics_layer( - &mut server, - pane_id, - &format!("layer-{index:02}"), - vec![index as u8; 1024 * 1024], - index as i32, - ); - } + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); + + server.render_and_stream(); + let first = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(first) = first else { + panic!("expected client shell pane surface"); + }; + assert_eq!(first.graphics.placements.len(), 1); + assert_eq!(first.graphics.assets.len(), 1); + assert_eq!(first.graphics.assets[0].data, vec![1, 2, 3, 4]); + assert!(matches!( + first.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::PaneLayer { .. } + )); + + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let second = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(second) = second else { + panic!("expected replacement client shell pane surface"); + }; + assert_eq!(second.graphics.placements, first.graphics.placements); + assert!(second.graphics.assets.is_empty()); +} + +#[tokio::test] +async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); + + server.render_and_stream(); + let _first = receive_render(&client_rx, Duration::from_millis(100)); + server.app.pane_graphics.slots.clear(); + server.app.pane_graphics.mark_changed(); + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let removed = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(removed) = removed else { + panic!("expected removed client shell scene"); + }; + assert!(removed.graphics.placements.is_empty()); + + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let restored = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(restored) = restored else { + panic!("expected restored client shell scene"); + }; + assert_eq!(restored.graphics.assets.len(), 1); + assert_eq!(restored.graphics.assets[0].data, vec![1, 2, 3, 4]); +} + +#[tokio::test] +async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { + let (mut server, client_rx, _pane_id) = + retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.assets.len(), 1); + assert!(matches!( + surface.graphics.placements[0].asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { .. }, + image_id: 7, + } + )); + assert_eq!(surface.graphics.assets[0].data, vec![255, 0, 0, 255]); +} + +#[tokio::test] +async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { + let (mut server, client_rx, _pane_id) = retained_test_server( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 2); + assert_eq!(surface.graphics.assets.len(), 2); + assert_ne!( + surface.graphics.assets[0].key, + surface.graphics.assets[1].key + ); + assert_eq!( + surface.graphics.assets[0].data, + surface.graphics.assets[1].data + ); + + server.clients.get_mut(&1).unwrap().request_repaint(); + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected replacement client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 2); + assert!(surface.graphics.assets.is_empty()); +} + +#[tokio::test] +async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + set_graphics_layer(&mut server, pane_id, vec![5, 6, 7, 8]); fill_render_lane(&server); server.render_and_stream(); - assert!(server.clients[&1].graphics_cache.is_empty()); - let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - - for expected in 1..=LAYERS { - server.render_and_stream(); - let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4); - let frame = read_server_frame(bytes); - assert_eq!( - frame - .graphics - .windows(4) - .filter(|part| *part == b"a=t,") - .count(), - 1 - ); - assert_eq!( - server.clients[&1].graphics_cache.test_image_count(), - expected - ); - } - assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); -} - -fn enable_graphics_and_render( - server: &mut HeadlessServer, - client_rx: &std::sync::mpsc::Receiver>, -) -> FrameData { - server.app.state.kitty_graphics_enabled = true; - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; + let _older = receive_render(&client_rx, Duration::from_millis(100)); server.render_and_stream(); - read_server_frame(receive_render(client_rx, Duration::from_millis(100))) + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]); } fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { @@ -258,151 +367,6 @@ async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() )); } -#[tokio::test] -async fn pixel_input_metadata_cannot_resize_authoritative_client_state() { - let (mut server, _client_rx, pane_id) = - retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); - set_graphics_layer(&mut server, pane_id, vec![1]); - let client = server.clients.get_mut(&1).unwrap(); - client.pixel_mouse = true; - client.host_sgr_pixels_active = Some(true); - server.foreground_client_id = None; - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: b"\x1b[<0;500;300M".to_vec(), - geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - })); - server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - for (geometry, data) in [ - ( - crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(), - b"\x1b[<0;500;300M".as_slice(), - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(), - b"\x1b[<0;500;300M", - ), - ( - crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), - b"\x1b[<0;0;1M", - ), - ] { - assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { - client_id: 1, - data: data.to_vec(), - geometry, - })); - } - assert_eq!(server.clients[&1].terminal_size, (80, 24)); - assert_eq!( - (server.effective_size, server.foreground_client_id), - ((80, 24), None) - ); -} - -#[test] -fn direct_eligibility_is_installed_with_the_client_connection() { - let mut server = test_headless_server(); - let (writer, _control_rx, _render_rx) = test_client_writer(); - - assert!(server.handle_server_event(ServerEvent::ClientConnected { - client_id: 7, - cols: 80, - rows: 24, - cell_width_px: 10, - cell_height_px: 20, - render_encoding: RenderEncoding::SemanticFrame, - keybindings: None, - direct_attach_requested: false, - direct_graphics: true, - writer, - })); - - let client = server.clients.get(&7).expect("connected client"); - assert!(client.direct_graphics); - assert_eq!(server.foreground_client_id, Some(7)); - assert!(server.app.direct_graphics_available); -} - -#[tokio::test] -async fn focus_repaint_preserves_uploaded_graphics() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let (client_2_writer, _client_2_control_rx, client_2_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - Some(false), - 0, - RenderEncoding::SemanticFrame, - Some(client_2_writer), - ), - ); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - let initial = enable_graphics_and_render(&mut server, &client_rx); - let initial_graphics = String::from_utf8_lossy(&initial.graphics); - assert!(initial_graphics.contains("a=t")); - assert!(initial_graphics.contains("a=p")); - let client_2_initial = - read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t")); - - assert!(server.handle_server_event(ServerEvent::ClientInput { - client_id: 2, - data: b"\x1b[I".to_vec(), - })); - assert_eq!(server.foreground_client_id, Some(2)); - server.render_and_stream(); - - let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); - let focused_graphics = String::from_utf8_lossy(&focused.graphics); - assert!(focused_graphics.contains("a=p")); - assert!(!focused_graphics.contains("a=t")); -} - -#[tokio::test] -async fn resize_replays_placement_without_retransmitting_or_closing_stream() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - set_stream_owner(&mut server, pane_id, "owner-resize"); - let initial = enable_graphics_and_render(&mut server, &client_rx); - assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t")); - - for (cols, rows, cell_width_px, cell_height_px) in - [(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)] - { - assert!(server.handle_server_event(ServerEvent::ClientResize { - client_id: 1, - cols, - rows, - cell_width_px, - cell_height_px, - })); - server.render_and_stream(); - let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100))); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(!graphics.contains("a=t")); - assert!(graphics.contains("a=p")); - } - assert_eq!( - server - .app - .pane_graphics - .slots - .get(&graphics_key(pane_id)) - .and_then(|slot| slot.stream_owner.as_deref()), - Some("owner-resize") - ); -} - #[tokio::test] async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); @@ -426,88 +390,6 @@ async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { assert!(server.app.pane_graphics.slots.is_empty()); } -#[tokio::test] -async fn retained_update_sends_only_graphics_message() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let baseline = enable_graphics_and_render(&mut server, &client_rx); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - match read_server_message( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("graphics-only update"), - ) { - ServerMessage::Graphics { bytes } => { - assert!(bytes.windows(3).any(|window| window == b"\x1b_G")); - } - other => panic!("expected graphics-only message, got {other:?}"), - } - assert_frame_data_eq( - server - .clients - .get(&1) - .unwrap() - .render_state - .last_frame() - .expect("semantic baseline"), - &baseline, - ); -} - -#[tokio::test] -async fn retained_graphics_stays_ordered_after_an_older_render() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - fill_render_lane(&server); - set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]); - let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Sent - ); - let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - read_server_message(older), - ServerMessage::ReloadSoundConfig - )); - assert!(matches!( - read_server_message(graphics), - ServerMessage::Graphics { .. } - )); -} - -#[tokio::test] -async fn retained_update_falls_back_for_mixed_app_geometry() { - let (mut server, client_rx, _pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (60, 20), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - crate::terminal_theme::TerminalTheme::default(), - None, - 2, - RenderEncoding::SemanticFrame, - Some(writer), - ), - ); - - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Fallback - ); -} - #[test] fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { let mut server = test_headless_server(); @@ -730,248 +612,103 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { #[cfg(unix)] #[tokio::test] -async fn hidden_large_direct_frame_uploads_then_replays_placement_without_closing_stream() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - - let image_width = 2_048; - let image_height = 2_049; - let expected_len = u64::from(image_width) * u64::from(image_height) * 4; - assert!(expected_len > api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES as u64); - let path = sparse_direct_frame( - &server, - "hidden-large-frame.rgba", - image_width, - image_height, - ); - let (message, response_rx) = direct_stream_message( - "hidden-frame", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); - - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - let (transfer_id, image_id, control, leading) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("hidden direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - control, - leading, - expected_len: sent_len, - .. - } => { - assert_eq!(sent_len, expected_len); - (transfer_id, image_id, control, leading) - } - other => panic!("expected graphics file, got {other:?}"), +async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { + let (mut server, client_rx, pane_id) = retained_test_server(b"client shell direct"); + server.app.state.kitty_graphics_enabled = true; + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, }; - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - assert!(!control.contains("p="), "{control}"); - assert!(response_rx.try_recv().is_err()); + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.direct_graphics_available = true; + set_stream_owner(&mut server, pane_id, "browser"); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let path = sparse_direct_frame(&server, "client-shell-direct.rgba", 1, 1); + let (message, response_rx) = + direct_stream_message("shell-direct", &public_pane_id, "browser", path, 1, 1); - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame while upload is pending"), - ); - assert!(!frame.graphics.windows(4).any(|bytes| bytes == b"a=p,")); - - server.app.state.workspaces[0].switch_tab(0); - server.render_and_stream(); - let _hidden_again = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("frame after hiding pending upload"), - ); - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(serde_json::from_str::( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert!(slot.layer.as_ref().unwrap().terminal_only()); - - server.app.state.workspaces[0].switch_tab(background_tab); - server.render_and_stream(); - let frame = read_server_frame( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("placement replay after tab switch"), - ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); - - let next_path = sparse_direct_frame( - &server, - "visible-next-frame.rgba", - image_width, - image_height, - ); - let (message, next_response_rx) = direct_stream_message( - "visible-frame", - &public_pane_id, - "browser", - next_path, - image_width, - image_height, - ); assert_eq!( server.handle_pane_graphics_stream_frame(message), RenderImpact::None ); - match read_server_message( + let (transfer_id, image_id, asset) = match read_server_message( client_rx .recv_timeout(Duration::from_secs(1)) - .expect("next visible direct frame"), - ) { - ServerMessage::GraphicsFile { control, .. } => { - assert!(control.starts_with("a=T,"), "{control}"); - } - other => panic!("expected graphics file, got {other:?}"), - } - assert!(next_response_rx.try_recv().is_err()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); -} - -#[cfg(unix)] -#[tokio::test] -async fn hidden_small_direct_frame_preserves_owned_inline_fallback() { - let (mut server, client_rx, _) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); - let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - - let path = sparse_direct_frame(&server, "hidden-small-frame.rgba", 1, 1); - let (message, response_rx) = - direct_stream_message("hidden-small", &public_pane_id, "browser", path, 1, 1); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Graphics - ); - assert!(serde_json::from_str::( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); - let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; - assert!(slot.stream_is_active()); - assert_eq!( - slot.layer.as_ref().unwrap().inline_data(), - Some([0; 4].as_slice()) - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn direct_frame_during_internal_redraw_uploads_without_placement() { - let (mut server, client_rx, pane_id) = retained_test_server(b"active"); - enable_graphics_and_render(&mut server, &client_rx); - let pane_number = server.app.state.workspaces[0] - .public_pane_number(pane_id) - .unwrap(); - let public_pane_id = crate::workspace::public_pane_id_for_number( - &server.app.state.workspaces[0].id, - pane_number, - ); - server.clients.get_mut(&1).unwrap().direct_graphics = true; - server.app.direct_graphics_available = true; - set_stream_owner(&mut server, pane_id, "browser"); - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: "9.9.9".into(), - install_command: "herdr update".into(), - }) - .unwrap(); - - let image_width = 2_048; - let image_height = 2_049; - let path = sparse_direct_frame(&server, "redraw-frame.rgba", image_width, image_height); - let (message, response_rx) = direct_stream_message( - "redraw", - &public_pane_id, - "browser", - path, - image_width, - image_height, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::Full - ); - let (transfer_id, image_id) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("direct upload during redraw"), + .expect("client shell direct upload"), ) { ServerMessage::GraphicsFile { - control, - leading, transfer_id, image_id, + leading, + control, + surface_asset: Some(asset), .. } => { assert!(leading.is_empty()); assert!(control.starts_with("a=t,"), "{control}"); - (transfer_id, image_id) + assert!(!control.contains("\u{1b}["), "{control}"); + (transfer_id, image_id, asset) } - other => panic!("expected graphics file, got {other:?}"), + other => panic!("expected client shell graphics file, got {other:?}"), }; + assert_eq!( + image_id, + crate::kitty_graphics::surface::host_image_id(&server.client_shell_boot_id, &asset) + ); + let (pending, _) = crate::server::client_shell_graphics::collect( + &server.app, + &[], + &[], + None, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + &crate::kitty_graphics::surface::DeliveryCache::default(), + 1, + ); + assert_eq!(pending.retained_assets, vec![asset.clone()]); + server.start_direct_graphics_response(1, transfer_id, image_id); assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(response_rx.recv_timeout(Duration::from_secs(1)).is_ok()); - assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); - + assert!(serde_json::from_str::( + &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() + ) + .is_ok()); + server.clients.get_mut(&1).unwrap().request_repaint(); server.render_and_stream(); - let frame = read_server_frame( + let surface = read_server_message( client_rx .recv_timeout(Duration::from_secs(1)) - .expect("placement after redraw upload acknowledgement"), + .expect("resident client shell scene"), ); - let graphics = String::from_utf8_lossy(&frame.graphics); - assert!(graphics.contains("a=p,"), "{graphics:?}"); - assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); - assert!(!graphics.contains("a=t,"), "{graphics:?}"); + let ServerMessage::PaneSurface(surface) = surface else { + panic!("expected client shell pane surface"); + }; + assert_eq!(surface.graphics.placements.len(), 1); + assert!(surface.graphics.assets.is_empty()); + assert_eq!(surface.graphics.placements[0].asset, asset); + assert_eq!(surface.graphics.retained_assets, vec![asset.clone()]); + + let (hidden, _) = crate::server::client_shell_graphics::collect( + &server.app, + &[], + &[], + None, + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + &crate::kitty_graphics::surface::DeliveryCache::default(), + 1, + ); + assert!(hidden.placements.is_empty()); + assert_eq!(hidden.retained_assets, vec![asset]); } #[cfg(unix)] @@ -1029,6 +766,7 @@ fn direct_gate_server_with_file( slot.stream_active = Some(active_gate()); slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { transfer_id: lease.fingerprint(), + image_id: (1 << 31) | 900, client_id: 7, deadline: std::time::Instant::now() + Duration::from_secs(1), written: true, @@ -1042,10 +780,8 @@ fn direct_gate_server_with_file( #[cfg(unix)] fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) { let slot = &server.app.pane_graphics.slots[key]; - ( - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - ) + let gate = slot.direct_gate.as_ref().unwrap(); + (gate.transfer_id, gate.image_id) } #[cfg(unix)] @@ -1058,8 +794,6 @@ fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { width_px: 10, height_px: 20, }, - crate::terminal_theme::TerminalTheme::default(), - None, 1, RenderEncoding::SemanticFrame, Some(writer), @@ -1129,50 +863,6 @@ fn matching_terminal_ok_releases_producer_and_acknowledges() { assert!(layer.direct_lease().is_none()); } -#[cfg(unix)] -#[test] -fn explicit_terminal_error_acks_only_after_owned_inline_fallback() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - server - .clients - .get_mut(&7) - .unwrap() - .graphics_cache - .trust_pane_layer(&key, image_id, layer); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - assert_eq!( - ( - response_rx.recv().unwrap(), - layer.inline_data(), - server.clients[&7].direct_graphics, - server.clients[&7].pixel_mouse, - ), - ("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true) - ); - assert!(server.clients[&7].graphics_cache.is_empty()); -} - -#[cfg(unix)] -#[test] -fn large_direct_terminal_error_closes_without_acknowledging_or_copying() { - let len = crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 4; - let (mut server, key, response_rx) = direct_gate_server_with_file(len, None); - add_direct_client(&mut server, 7); - let (transfer_id, image_id) = direct_ids(&server, &key); - - assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(matches!( - response_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Disconnected) - )); -} - #[cfg(unix)] #[test] fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { @@ -1215,6 +905,43 @@ fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() } } +#[cfg(unix)] +#[test] +fn eligibility_loss_cancels_the_queued_direct_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + + server.retire_all_direct_graphics(); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("direct retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + #[cfg(unix)] #[test] fn client_loss_retires_only_its_direct_stream() { @@ -1240,6 +967,44 @@ fn client_loss_retires_only_its_direct_stream() { assert!(!resident.app.pane_graphics.slots.contains_key(&key)); } +#[cfg(unix)] +#[test] +fn pane_removal_cancels_the_pending_client_upload() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (writer, control_rx, _render_rx) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(7, client); + let (transfer_id, image_id) = direct_ids(&server, &key); + server.app.state.workspaces.clear(); + + assert!(server.retain_live_pane_graphics()); + + assert!(matches!( + read_server_message( + control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("pane removal retirement") + ), + ServerMessage::GraphicsTransmissionRetired { + transfer_id: retired_transfer, + image_id: retired_image, + } if retired_transfer == transfer_id && retired_image == image_id + )); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); +} + #[cfg(unix)] #[test] fn pane_removal_and_shutdown_drop_direct_without_ack() { diff --git a/src/server/mod.rs b/src/server/mod.rs index 8566b4bd..8b155df1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2,6 +2,9 @@ mod alt_screen_read; pub mod autodetect; #[cfg(unix)] pub(crate) mod client_accept; +pub(crate) mod client_commands; +pub(crate) mod client_shell; +pub(crate) mod client_shell_graphics; pub(crate) mod client_transport; pub(crate) mod clients; pub(crate) mod clipboard_image; @@ -10,6 +13,9 @@ pub(crate) mod handoff; pub mod headless; pub(crate) mod keybindings; pub(crate) mod notifications; +pub(crate) mod pane_input; +#[cfg(test)] +mod render_scale_benchmark; pub(crate) mod render_stream; pub mod socket_paths; pub(crate) mod terminal_attach; diff --git a/src/server/pane_input.rs b/src/server/pane_input.rs new file mode 100644 index 00000000..4eb64cab --- /dev/null +++ b/src/server/pane_input.rs @@ -0,0 +1,515 @@ +use bytes::Bytes; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; + +use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientPaneInputEvent}; + +pub(super) fn downgrade_ineligible_pixel_mouse( + events: &mut [ClientPaneInputEvent], + pixel_mouse: bool, + runtime_size: (u16, u16), + runtime_pixels: Option<(u32, u32)>, +) { + let (runtime_rows, runtime_cols) = runtime_size; + for event in events { + let ClientPaneInputEvent::Mouse { + position, geometry, .. + } = event + else { + continue; + }; + let crate::protocol::ClientMousePosition::Pixels { x, y, column, row } = *position else { + continue; + }; + let exact = pixel_mouse + && geometry.is_some_and(|geometry| { + (runtime_rows, runtime_cols) == (geometry.rows, geometry.cols) + && runtime_pixels == Some((geometry.width_px, geometry.height_px)) + && column < geometry.cols + && row < geometry.rows + && x > 0 + && y > 0 + && x <= geometry.width_px + && y <= geometry.height_px + }); + if !exact { + *position = crate::protocol::ClientMousePosition::Cell { column, row }; + *geometry = None; + } + } +} + +pub(super) fn terminal_attach_mouse_position( + runtime: &crate::terminal::TerminalRuntime, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + pixel_mouse: bool, + host_sgr_pixels_active: bool, + position: crate::protocol::ClientMousePosition, + geometry: Option, +) -> Option { + let runtime_size = runtime.current_size(); + let cell_fallback = |column, row| { + (column < runtime_size.1 && row < runtime_size.0) + .then_some(crate::protocol::ClientMousePosition::Cell { column, row }) + }; + let (x, y, column, row) = match position { + crate::protocol::ClientMousePosition::Cell { column, row } => { + return cell_fallback(column, row); + } + crate::protocol::ClientMousePosition::Pixels { x, y, column, row } => (x, y, column, row), + }; + let Some(geometry) = geometry else { + return cell_fallback(column, row); + }; + let host_geometry = crate::input::mouse::HostGeometry::new( + geometry.cols, + geometry.rows, + geometry.width_px, + geometry.height_px, + )?; + if host_geometry.cell(x, y) != Some((column, row)) { + return None; + } + let exact = (|| { + let average_width = (geometry.width_px / u32::from(geometry.cols)).max(1); + let average_height = (geometry.height_px / u32::from(geometry.rows)).max(1); + let (child_width_px, child_height_px) = runtime.pixel_size()?; + if !pixel_mouse + || !host_sgr_pixels_active + || !runtime.sgr_pixel_mouse_enabled() + || terminal_size != (geometry.cols, geometry.rows) + || runtime_size != (geometry.rows, geometry.cols) + || !cell_size.is_known() + || average_width != cell_size.width_px + || average_height != cell_size.height_px + { + return None; + } + let crate::input::mouse::Position::Pixels { x, y } = (crate::input::mouse::HostPixels { + x, + y, + geometry: host_geometry, + }) + .pane_position( + ratatui::layout::Rect::new(0, 0, geometry.cols, geometry.rows), + child_width_px, + child_height_px, + )? + else { + return None; + }; + Some(crate::protocol::ClientMousePosition::Pixels { x, y, column, row }) + })(); + exact.or_else(|| cell_fallback(column, row)) +} + +pub(super) fn apply_terminal_attach_scroll( + runtime: &crate::terminal::TerminalRuntime, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + column: Option, + row: Option, + modifiers: u8, +) -> Result<(), String> { + apply_scroll( + runtime, + source, + direction, + lines, + crate::input::mouse::Position::Cell { + column: column.unwrap_or(0), + row: row.unwrap_or(0), + }, + modifiers, + ) +} + +fn apply_scroll( + runtime: &crate::terminal::TerminalRuntime, + source: AttachScrollSource, + direction: AttachScrollDirection, + lines: u16, + position: crate::input::mouse::Position, + modifiers: u8, +) -> Result<(), String> { + let wheel_kind = match direction { + AttachScrollDirection::Up => MouseEventKind::ScrollUp, + AttachScrollDirection::Down => MouseEventKind::ScrollDown, + }; + if let AttachScrollSource::PageKey { input } = source { + let host_scroll = runtime + .plain_page_keys_use_host_scrollback() + .unwrap_or(false); + if host_scroll { + match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + } + return Ok(()); + } + return apply_terminal_attach_input(runtime, input); + } + + match runtime.wheel_routing() { + Some(crate::pane::WheelRouting::MouseReport) => { + runtime.scroll_reset(); + let Some(bytes) = runtime.encode_mouse_wheel( + wheel_kind, + position, + KeyModifiers::from_bits_truncate(modifiers), + ) else { + return Err(format!( + "failed to encode terminal attach mouse wheel event: {wheel_kind:?}" + )); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + runtime.scroll_reset(); + let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else { + return Ok(()); + }; + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?; + } + Some(crate::pane::WheelRouting::HostScroll) | None => match direction { + AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize), + AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize), + }, + } + Ok(()) +} + +pub(super) fn apply_terminal_attach_input( + runtime: &crate::terminal::TerminalRuntime, + data: Vec, +) -> Result<(), String> { + runtime.scroll_reset(); + if let Some(text) = crate::raw_input::complete_text_bracketed_paste(&data) { + runtime + .try_send_paste(text.to_owned()) + .map_err(|err| format!("terminal attach paste failed: {err}")) + } else { + runtime + .try_send_bytes(Bytes::from(data)) + .map_err(|err| format!("terminal attach input failed: {err}")) + } +} + +pub(super) fn apply_client_pane_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], +) -> Result<(), String> { + apply_client_terminal_input_events(runtime, events, true) +} + +pub(super) fn apply_client_popup_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], +) -> Result<(), String> { + apply_client_terminal_input_events(runtime, events, false) +} + +fn apply_client_terminal_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], + host_page_keys: bool, +) -> Result<(), String> { + for event in events { + if let ClientPaneInputEvent::Mouse { + kind, + position, + modifiers, + lines, + .. + } = event + { + let kind = kind.to_crossterm(); + let modifiers = KeyModifiers::from_bits_truncate(*modifiers); + let position = match position { + crate::protocol::ClientMousePosition::Cell { column, row } => { + crate::input::mouse::Position::Cell { + column: *column, + row: *row, + } + } + crate::protocol::ClientMousePosition::Pixels { x, y, column, row } => { + if runtime.sgr_pixel_mouse_enabled() { + crate::input::mouse::Position::Pixels { x: *x, y: *y } + } else { + crate::input::mouse::Position::Cell { + column: *column, + row: *row, + } + } + } + }; + let bytes = match kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if kind == MouseEventKind::ScrollUp { + AttachScrollDirection::Up + } else { + AttachScrollDirection::Down + }; + apply_scroll( + runtime, + AttachScrollSource::Wheel, + direction, + (*lines).max(1), + position, + modifiers.bits(), + )?; + continue; + } + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => runtime + .encode_mouse_wheel(kind, position, modifiers) + .unwrap_or_default(), + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + runtime + .encode_mouse_button(kind, position, modifiers) + .unwrap_or_default() + } + MouseEventKind::Moved => runtime + .encode_mouse_motion(kind, position, modifiers) + .unwrap_or_default(), + }; + if !bytes.is_empty() { + if kind != MouseEventKind::Moved { + runtime.scroll_reset(); + } + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("targeted pane mouse input failed: {err}"))?; + } + continue; + } + + match event.to_raw_input_event() { + crate::raw_input::RawInputEvent::Key(key) => { + let key_event = key.as_key_event(); + if host_page_keys + && matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) + && key_event.modifiers.is_empty() + && runtime.plain_page_keys_use_host_scrollback() == Some(true) + { + match key_event.kind { + KeyEventKind::Release => continue, + KeyEventKind::Press | KeyEventKind::Repeat => { + let lines = runtime.current_size().0.max(1) as usize; + if key_event.code == KeyCode::PageUp { + runtime.scroll_up(lines); + } else { + runtime.scroll_down(lines); + } + continue; + } + } + } + + runtime.scroll_reset(); + let bytes = runtime.encode_terminal_key(key); + if !bytes.is_empty() { + runtime + .try_send_bytes(Bytes::from(bytes)) + .map_err(|err| format!("targeted pane key input failed: {err}"))?; + } + } + crate::raw_input::RawInputEvent::Text(text) => { + runtime.scroll_reset(); + runtime + .try_send_bytes(Bytes::copy_from_slice(text.as_str().as_bytes())) + .map_err(|err| format!("targeted pane text input failed: {err}"))?; + } + crate::raw_input::RawInputEvent::Paste(text) => { + runtime.scroll_reset(); + runtime + .try_send_paste(text) + .map_err(|err| format!("targeted pane paste failed: {err}"))?; + } + crate::raw_input::RawInputEvent::Mouse(_) + | crate::raw_input::RawInputEvent::OuterFocusGained + | crate::raw_input::RawInputEvent::OuterFocusLost + | crate::raw_input::RawInputEvent::HostDefaultColor { .. } + | crate::raw_input::RawInputEvent::HostPaletteColors { .. } + | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) + | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } + | crate::raw_input::RawInputEvent::Unsupported => { + return Err("non-pane input reached targeted pane input".to_owned()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn terminal_attach_stale_geometry_falls_back_to_the_canonical_cell() { + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b""); + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + position, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (20, 5), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + true, + false, + crate::protocol::ClientMousePosition::Pixels { + x: 120, + y: 80, + column: 12, + row: 4, + }, + Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + ), + None + ); + assert_eq!( + terminal_attach_mouse_position( + &runtime, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + false, + false, + crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + None, + ), + Some(crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }) + ); + } + + #[test] + fn ineligible_shell_pixel_mouse_uses_its_canonical_cell_position() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left), + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, false, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + .. + }] + )); + } + + #[test] + fn eligible_shell_pixel_mouse_remains_exact() { + let position = crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }; + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (5, 20), Some((200, 100))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: current, + .. + }] if *current == position + )); + } + + #[test] + fn stale_shell_pixel_geometry_downgrades_to_its_canonical_cell() { + let mut events = vec![ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Pixels { + x: 121, + y: 81, + column: 12, + row: 4, + }, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: 20, + rows: 5, + width_px: 200, + height_px: 100, + }), + modifiers: 0, + lines: 1, + }]; + + downgrade_ineligible_pixel_mouse(&mut events, true, (6, 20), Some((200, 120))); + + assert!(matches!( + events.as_slice(), + [ClientPaneInputEvent::Mouse { + position: crate::protocol::ClientMousePosition::Cell { column: 12, row: 4 }, + geometry: None, + .. + }] + )); + } +} diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs new file mode 100644 index 00000000..e533bd03 --- /dev/null +++ b/src/server/render_scale_benchmark.rs @@ -0,0 +1,218 @@ +use std::hint::black_box; +use std::time::{Duration, Instant}; + +use ratatui::layout::{Direction, Rect}; + +use crate::app::{App, AppPolicy}; +use crate::client::{ClientShellConfig, ClientShellState}; +use crate::config::Config; +use crate::kitty_graphics::HostCellSize; +use crate::protocol::PaneSurfaceFrame; +use crate::terminal::TerminalRuntime; +use crate::workspace::Workspace; + +const COLS: u16 = 120; +const ROWS: u16 = 40; +const SAMPLE_COUNT: usize = 40; +const WARMUP_COUNT: usize = 5; +const CARDINALITIES: [usize; 3] = [1, 15, 50]; + +#[derive(Clone, Copy)] +struct StageStats { + median_us: u128, + p95_us: u128, + max_us: u128, +} + +struct PipelineStats { + server: StageStats, + client: StageStats, + total: StageStats, +} + +struct RenderPipeline { + app: App, + client: ClientShellState, + graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, +} + +impl RenderPipeline { + fn new(workspaces: Vec) -> Self { + let config = Config::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &config, + AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = workspaces; + app.state.active = Some(0); + app.state.selected = 0; + app.state.pane_scrollbars = true; + + let mut client = ClientShellState::new(ClientShellConfig::from_config(&config)); + client.set_snapshot(Box::new(super::client_shell::snapshot( + &app, + "bench-boot", + 1, + None, + ))); + + Self { + app, + client, + graphics_delivery: crate::kitty_graphics::surface::DeliveryCache::default(), + } + } + + fn render_once(&mut self) -> (Duration, Duration) { + let surface_size = self.client.surface_size(COLS, ROWS); + let started = Instant::now(); + let rendered = super::client_shell::render_pane_surface( + &mut self.app, + Rect::new(0, 0, surface_size.cols, surface_size.rows), + true, + HostCellSize { + width_px: 1, + height_px: 1, + }, + &self.graphics_delivery, + 1, + ); + let server_elapsed = started.elapsed(); + self.graphics_delivery = rendered.graphics_delivery; + + let started = Instant::now(); + self.client.set_pane_surface(PaneSurfaceFrame { + boot_id: "bench-boot".into(), + projection_revision: 1, + surface_revision: 0, + frame: rendered.frame, + panes: rendered.panes, + splits: rendered.splits, + popup: rendered.popup, + graphics: rendered.graphics, + }); + black_box( + self.client + .compose(COLS, ROWS) + .expect("benchmark pipeline should compose a complete frame"), + ); + let client_elapsed = started.elapsed(); + + (server_elapsed, client_elapsed) + } +} + +fn history() -> String { + (0..2_000).map(|line| format!("line-{line}\r\n")).collect() +} + +fn runtime(history: &str) -> TerminalRuntime { + TerminalRuntime::test_with_scrollback_bytes(COLS, ROWS, 1024 * 1024, history.as_bytes()) +} + +fn workspaces(workspace_count: usize) -> Vec { + let history = history(); + (0..workspace_count) + .map(|index| { + let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); + let root_pane = workspace.tabs[0].root_pane; + workspace.insert_test_runtime(root_pane, runtime(&history)); + workspace + }) + .collect() +} + +fn active_panes(pane_count: usize) -> Vec { + let history = history(); + let mut workspace = Workspace::test_new("bench"); + let root_pane = workspace.tabs[0].root_pane; + workspace.insert_test_runtime(root_pane, runtime(&history)); + let mut pane_ids = vec![root_pane]; + + for index in 1..pane_count { + let target = pane_ids[(index - 1) / 2]; + workspace.tabs[0].layout.focus_pane(target); + let direction = if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }; + let pane_id = workspace.test_split(direction); + workspace.insert_test_runtime(pane_id, runtime(&history)); + pane_ids.push(pane_id); + } + + vec![workspace] +} + +fn summarize(mut samples: Vec) -> StageStats { + samples.sort_unstable(); + StageStats { + median_us: samples[SAMPLE_COUNT / 2].as_micros(), + p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100].as_micros(), + max_us: samples[SAMPLE_COUNT - 1].as_micros(), + } +} + +fn profile(build: fn(usize) -> Vec, count: usize) -> PipelineStats { + let mut pipeline = RenderPipeline::new(build(count)); + for _ in 0..WARMUP_COUNT { + black_box(pipeline.render_once()); + } + + let mut server = Vec::with_capacity(SAMPLE_COUNT); + let mut client = Vec::with_capacity(SAMPLE_COUNT); + let mut total = Vec::with_capacity(SAMPLE_COUNT); + for _ in 0..SAMPLE_COUNT { + let (server_elapsed, client_elapsed) = pipeline.render_once(); + server.push(server_elapsed); + client.push(client_elapsed); + total.push(server_elapsed + client_elapsed); + } + + PipelineStats { + server: summarize(server), + client: summarize(client), + total: summarize(total), + } +} + +fn print_stage( + label: &str, + rows: &[(usize, PipelineStats)], + stage: fn(&PipelineStats) -> StageStats, +) { + let baseline = stage(&rows[0].1); + println!(" {label}"); + println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); + for (count, pipeline) in rows { + let stats = stage(pipeline); + println!( + " {count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", + stats.median_us, + stats.p95_us, + stats.max_us, + stats.median_us as f64 / baseline.median_us.max(1) as f64, + stats.p95_us as f64 / baseline.p95_us.max(1) as f64, + ); + } +} + +fn print_profiles(label: &str, build: fn(usize) -> Vec) { + let rows = CARDINALITIES.map(|count| (count, profile(build, count))); + println!("{label}"); + print_stage("server pane surface", &rows, |stats| stats.server); + print_stage("client shell composition", &rows, |stats| stats.client); + print_stage("combined pipeline", &rows, |stats| stats.total); +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "manual client-rendered pipeline scaling profile"] +async fn render_scale_profile() { + print_profiles("background workspaces (one pane each)", workspaces); + print_profiles("active panes (one workspace)", active_panes); +} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index f14deb5e..3d9e1141 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -4,15 +4,20 @@ use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; -use crate::app::Mode; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; -use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; +use crate::protocol::{ + CursorState, FrameData, PaneSurfaceFrame, PaneSurfacePatch, RenderEncoding, ServerMessage, + TerminalFrame, +}; use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { /// Semantic clients compare full frame data and skip identical frames. - Semantic { last_frame: Option }, + Semantic { + last_surface: Option>, + surface_revision: u64, + }, /// Terminal-ANSI clients keep a terminal diff encoder and sequence number. TerminalAnsi { blit_encoder: BlitEncoder, @@ -24,7 +29,10 @@ pub(crate) enum ClientRenderState { impl ClientRenderState { pub(crate) fn new(render_encoding: RenderEncoding) -> Self { match render_encoding { - RenderEncoding::SemanticFrame => Self::Semantic { last_frame: None }, + RenderEncoding::SemanticFrame => Self::Semantic { + last_surface: None, + surface_revision: 0, + }, RenderEncoding::TerminalAnsi => Self::TerminalAnsi { blit_encoder: BlitEncoder::new(), seq: 0, @@ -35,7 +43,7 @@ impl ClientRenderState { pub(crate) fn reset_baseline(&mut self) { match self { - Self::Semantic { last_frame } => *last_frame = None, + Self::Semantic { last_surface, .. } => *last_surface = None, Self::TerminalAnsi { blit_encoder, repaint_pending, @@ -49,31 +57,16 @@ impl ClientRenderState { pub(crate) fn request_repaint(&mut self) { match self { - Self::Semantic { last_frame } => *last_frame = None, + Self::Semantic { last_surface, .. } => *last_surface = None, Self::TerminalAnsi { repaint_pending, .. } => *repaint_pending = true, } } - pub(crate) fn reset_semantic_input_baseline(&mut self) { - if let Self::Semantic { last_frame } = self { - *last_frame = None; - } - } - pub(crate) fn prepare_frame(&mut self, frame: FrameData) -> Option { match self { - Self::Semantic { last_frame } => { - if last_frame.as_ref() == Some(&frame) { - crate::render_prof::event("prepare_frame.semantic.skip_current"); - return None; - } - crate::render_prof::event("prepare_frame.semantic.changed"); - Some(PreparedRender::Semantic { - message: ServerMessage::Frame(frame), - }) - } + Self::Semantic { .. } => None, Self::TerminalAnsi { blit_encoder, seq, @@ -111,21 +104,89 @@ impl ClientRenderState { } } - pub(crate) fn last_frame(&self) -> Option<&FrameData> { + pub(crate) fn last_pane_surface(&self) -> Option<&PaneSurfaceFrame> { match self { - Self::Semantic { last_frame } => last_frame.as_ref(), - Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(), + Self::Semantic { last_surface, .. } => last_surface.as_deref(), + Self::TerminalAnsi { .. } => None, } } + pub(crate) fn prepare_pane_surface( + &mut self, + mut surface: PaneSurfaceFrame, + ) -> Option { + let Self::Semantic { + last_surface, + surface_revision, + } = self + else { + return None; + }; + if surface.graphics.assets.is_empty() + && last_surface.as_deref().is_some_and(|last| { + last.projection_revision == surface.projection_revision + && last.frame == surface.frame + && last.panes == surface.panes + && last.splits == surface.splits + && last.popup == surface.popup + && last.graphics.placements == surface.graphics.placements + && last.graphics.retained_assets == surface.graphics.retained_assets + }) + { + return None; + } + surface.surface_revision = surface_revision.saturating_add(1); + let mut committed_surface = surface.clone(); + committed_surface.graphics.assets.clear(); + Some(PreparedRender::Semantic { + message: ServerMessage::PaneSurface(surface), + committed_surface: Box::new(committed_surface), + }) + } + + pub(crate) fn prepare_pane_surface_patch( + &mut self, + mut patch: PaneSurfacePatch, + mut committed_surface: PaneSurfaceFrame, + ) -> Option { + let Self::Semantic { + last_surface, + surface_revision, + } = self + else { + return None; + }; + let last = last_surface.as_deref()?; + if last.boot_id != patch.boot_id + || last.projection_revision != patch.projection_revision + || last.surface_revision != patch.base_surface_revision + { + return None; + } + let next_revision = surface_revision.saturating_add(1); + patch.surface_revision = next_revision; + committed_surface.surface_revision = next_revision; + committed_surface.graphics.assets.clear(); + Some(PreparedRender::Semantic { + message: ServerMessage::PaneSurfacePatch(patch), + committed_surface: Box::new(committed_surface), + }) + } + pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) { match (self, prepared) { ( - Self::Semantic { last_frame }, - PreparedRender::Semantic { - message: ServerMessage::Frame(frame), + Self::Semantic { + last_surface, + surface_revision, }, - ) => *last_frame = Some(frame), + PreparedRender::Semantic { + committed_surface, .. + }, + ) => { + *surface_revision = committed_surface.surface_revision; + *last_surface = Some(committed_surface); + } ( Self::TerminalAnsi { blit_encoder, @@ -145,14 +206,6 @@ impl ClientRenderState { _ => {} } } - - #[cfg(test)] - pub(crate) fn terminal_seq(&self) -> Option { - match self { - Self::Semantic { .. } => None, - Self::TerminalAnsi { seq, .. } => Some(*seq), - } - } } fn insert_graphics_before_sync_end(encoded: &mut Vec, graphics: &[u8]) { @@ -171,6 +224,7 @@ fn insert_graphics_before_sync_end(encoded: &mut Vec, graphics: &[u8]) { pub(crate) enum PreparedRender { Semantic { message: ServerMessage, + committed_surface: Box, }, TerminalAnsi { message: ServerMessage, @@ -182,18 +236,23 @@ pub(crate) enum PreparedRender { impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { match self { - Self::Semantic { message } | Self::TerminalAnsi { message, .. } => message, + Self::Semantic { message, .. } | Self::TerminalAnsi { message, .. } => message, } } - pub(crate) fn into_frame(self) -> Option { - match self { - Self::Semantic { - message: ServerMessage::Frame(frame), - } => Some(frame), - Self::TerminalAnsi { frame, .. } => Some(frame), - _ => None, + pub(crate) fn strip_pane_surface_assets(&mut self) -> bool { + let Self::Semantic { + message: ServerMessage::PaneSurface(surface), + .. + } = self + else { + return false; + }; + if surface.graphics.assets.is_empty() { + return false; } + surface.graphics.assets.clear(); + true } } @@ -280,88 +339,44 @@ impl Backend for CursorTrackingBackend { } } -/// Renders the AppState to an in-memory ratatui Buffer. -/// -/// This produces the same output as the monolithic binary's terminal draw, -/// but writes to a `Buffer` instead of stdout. Cursor visibility is captured -/// from explicit frame cursor intent rather than incidental backend state. -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn render_virtual( - app_state: &mut AppState, - area: Rect, - resize_panes: bool, -) -> (ratatui::buffer::Buffer, Option) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - render_virtual_with_runtime_registry( - app_state, - &terminal_runtimes, - area, - resize_panes, - crate::kitty_graphics::HostCellSize::default(), - ) -} +pub(crate) type RenderedTabSurface = ( + ratatui::buffer::Buffer, + Option, + Vec<((u16, u16), String, String)>, + crate::ui::TabSurfaceLayout, +); -pub(crate) fn render_virtual_with_runtime_registry( - app_state: &mut AppState, +/// Renders only the active tab's pane surface at an origin-relative client viewport. +pub(crate) fn render_tab_surface_virtual( + app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, -) -> (ratatui::buffer::Buffer, Option) { - let popup_visible = app_state.popup_pane.is_some(); - let pre_compute_suppresses_focused_terminal_cursor = - !popup_visible && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); - if resize_panes { - crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); - } else { - crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); - } - let suppress_focused_terminal_cursor = pre_compute_suppresses_focused_terminal_cursor - || (!popup_visible - && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes)); +) -> RenderedTabSurface { + let layout = + crate::ui::compute_tab_surface(app_state, terminal_runtimes, area, resize_panes, cell_size); + let surface = crate::ui::TabSurfaceView { + pane_infos: &layout.pane_infos, + split_borders: &layout.split_borders, + }; + let cursor = crate::ui::tab_surface_cursor(app_state, terminal_runtimes, surface); + let hyperlinks = crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, surface); let backend = CursorTrackingBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); - terminal .draw(|frame| { - crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); + crate::ui::render_tab_surface(app_state, terminal_runtimes, surface, frame); }) .expect("render to TestBackend should never fail"); - let buffer = terminal.backend().buffer().clone(); - let cursor = if popup_visible { - popup_terminal_cursor(app_state, terminal_runtimes) - } else if suppress_focused_terminal_cursor { - None - } else { - focused_terminal_cursor(app_state, terminal_runtimes).or_else(|| { - (!focused_terminal_owns_host_cursor(app_state, terminal_runtimes)) - .then(|| terminal.backend().rendered_cursor()) - .flatten() - }) - }; - - (buffer, cursor) -} - -fn popup_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option { - let popup = app_state.popup_pane.as_ref()?; - let runtime = terminal_runtimes.get(&popup.terminal_id)?; - if runtime.synchronized_output_active() { - return None; - } - let (_, inner) = crate::ui::popup_pane_rects(app_state, app_state.view.terminal_area)?; - let cursor = runtime.cursor_state(inner, true)?; - Some(CursorState { - x: cursor.x, - y: cursor.y, - visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), - shape: cursor.shape, - }) + ( + terminal.backend().buffer().clone(), + cursor, + hyperlinks, + layout, + ) } /// Renders one server-owned terminal directly for `terminal attach` clients. @@ -398,242 +413,66 @@ pub(crate) fn render_terminal_virtual( (buffer, cursor) } -pub(crate) fn visible_hyperlinks( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Vec<((u16, u16), String, String)> { - crate::ui::tab_surface_hyperlinks(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -pub(crate) fn focused_terminal_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> Option { - crate::ui::tab_surface_cursor(app_state, terminal_runtimes, app_state.view.tab_surface()) -} - -fn focused_terminal_owns_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some() -} - -fn focused_terminal_suppresses_host_cursor( - app_state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> bool { - if app_state.mode != Mode::Terminal { - return false; - } - - let Some(ws_idx) = app_state.active else { - return false; - }; - let Some(info) = app_state - .view - .pane_infos - .iter() - .find(|info| info.is_focused) - else { - return false; - }; - if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { - return false; - } - - app_state - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - .is_some_and(crate::terminal::TerminalRuntime::synchronized_output_active) -} - #[cfg(test)] -mod render_scale_benchmark { - use std::hint::black_box; - use std::time::Instant; - - use ratatui::layout::Direction; - +mod tests { use super::*; - use crate::app::Mode; - use crate::terminal::TerminalRuntime; - use crate::workspace::Workspace; + use crate::protocol::ClientShellPopupSurface; - const AREA: Rect = Rect::new(0, 0, 120, 40); - const SAMPLE_COUNT: usize = 40; - const WARMUP_COUNT: usize = 5; - - #[derive(Clone, Copy)] - struct RenderStats { - median_us: u128, - p95_us: u128, - max_us: u128, - } - - fn history() -> String { - (0..2_000).map(|line| format!("line-{line}\r\n")).collect() - } - - fn runtime(history: &str) -> TerminalRuntime { - TerminalRuntime::test_with_scrollback_bytes( - AREA.width, - AREA.height, - 1024 * 1024, - history.as_bytes(), - ) - } - - fn app_with_workspaces(workspace_count: usize) -> AppState { - let history = history(); - let workspaces = (0..workspace_count) - .map(|index| { - let mut workspace = Workspace::test_new(&format!("bench-{}", index + 1)); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - workspace - }) - .collect(); - app_with(workspaces) - } - - fn app_with_active_panes(pane_count: usize) -> AppState { - let history = history(); - let mut workspace = Workspace::test_new("bench"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0] - .runtimes - .insert(root_pane, runtime(&history)); - let mut pane_ids = vec![root_pane]; - - for index in 1..pane_count { - let target = pane_ids[(index - 1) / 2]; - workspace.tabs[0].layout.focus_pane(target); - let direction = if index % 2 == 0 { - Direction::Vertical - } else { - Direction::Horizontal - }; - let pane_id = workspace.test_split(direction); - workspace.tabs[0] - .runtimes - .insert(pane_id, runtime(&history)); - pane_ids.push(pane_id); - } - - app_with(vec![workspace]) - } - - fn app_with(workspaces: Vec) -> AppState { - let mut app = AppState::test_new(); - app.mode = Mode::Terminal; - app.pane_scrollbars = true; - app.workspaces = workspaces; - app.active = Some(0); - app.selected = 0; - app - } - - fn profile(mut app: AppState) -> RenderStats { - for _ in 0..WARMUP_COUNT { - black_box(render_virtual(&mut app, AREA, true)); - } - - let mut samples = Vec::with_capacity(SAMPLE_COUNT); - for _ in 0..SAMPLE_COUNT { - let started = Instant::now(); - black_box(render_virtual(&mut app, AREA, true)); - samples.push(started.elapsed().as_micros()); - } - samples.sort_unstable(); - - RenderStats { - median_us: samples[SAMPLE_COUNT / 2], - p95_us: samples[(SAMPLE_COUNT - 1) * 95 / 100], - max_us: samples[SAMPLE_COUNT - 1], + fn popup_surface(content: &str) -> PaneSurfaceFrame { + let pane = ratatui::buffer::Buffer::with_lines(["pane"]); + let popup = ratatui::buffer::Buffer::with_lines([content]); + PaneSurfaceFrame { + boot_id: "boot-1".into(), + projection_revision: 1, + surface_revision: 1, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&pane, None, &[]), + panes: Vec::new(), + splits: Vec::new(), + popup: Some(Box::new(ClientShellPopupSurface { + terminal_id: "popup-terminal".into(), + title: "popup".into(), + width: None, + height: None, + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&popup, None, &[]), + mouse_reporting: false, + sgr_pixel_mouse: false, + pixel_width: 0, + pixel_height: 0, + })), + graphics: crate::protocol::SurfaceGraphicsScene::default(), } } - fn profile_cardinalities(build: fn(usize) -> AppState) -> [(usize, RenderStats); 3] { - [1, 15, 50].map(|count| (count, profile(build(count)))) + #[test] + fn popup_only_surface_changes_are_not_deduplicated() { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + let prepared = state + .prepare_pane_surface(popup_surface("first")) + .expect("initial surface"); + state.commit_sent_frame(prepared); + + assert!(state + .prepare_pane_surface(popup_surface("second")) + .is_some()); } - fn print_profiles(label: &str, profiles: [(usize, RenderStats); 3]) { - let baseline_median_us = profiles[0].1.median_us as f64; - let baseline_p95_us = profiles[0].1.p95_us as f64; - println!("{label}"); - println!(" count median_us p95_us max_us median_vs_1x p95_vs_1x"); - for (count, stats) in profiles { - println!( - "{count:>10} {:>9} {:>6} {:>6} {:>12.2} {:>9.2}", - stats.median_us, - stats.p95_us, - stats.max_us, - stats.median_us as f64 / baseline_median_us, - stats.p95_us as f64 / baseline_p95_us, - ); - } - } + #[test] + fn forced_full_surface_keeps_the_connection_revision_monotonic() { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + let prepared = state + .prepare_pane_surface(popup_surface("first")) + .expect("initial surface"); + state.commit_sent_frame(prepared); + state.request_repaint(); - fn assert_full_render_avoids_aggregate_input_state(mut app: AppState, scenario: &str) { - crate::pane::reset_aggregate_input_state_reads(); - black_box(render_virtual(&mut app, AREA, true)); - assert_eq!( - crate::pane::aggregate_input_state_reads(), - 0, - "full render collected aggregate input state for {scenario}", - ); - } - - #[tokio::test(flavor = "current_thread")] - async fn aggregate_input_state_counter_records_reads() { - let runtime = TerminalRuntime::test_with_screen_bytes(80, 24, b""); - crate::pane::reset_aggregate_input_state_reads(); - black_box(runtime.input_state()); - assert_eq!(crate::pane::aggregate_input_state_reads(), 1); - } - - #[tokio::test(flavor = "current_thread")] - async fn full_render_avoids_aggregate_input_state_reads() { - assert_full_render_avoids_aggregate_input_state( - app_with_workspaces(15), - "background workspaces", - ); - assert_full_render_avoids_aggregate_input_state(app_with_active_panes(15), "active panes"); - } - - #[tokio::test(flavor = "current_thread")] - #[ignore = "manual full-render scaling profile"] - async fn render_scale_profile() { - print_profiles( - "background-workspace resize/layout (one pane each)", - profile_cardinalities(app_with_workspaces), - ); - print_profiles( - "active panes (one workspace)", - profile_cardinalities(app_with_active_panes), - ); + let prepared = state + .prepare_pane_surface(popup_surface("replacement")) + .expect("forced replacement surface"); + assert!(matches!( + prepared.message(), + ServerMessage::PaneSurface(surface) if surface.surface_revision == 2 + )); + state.commit_sent_frame(prepared); + assert_eq!(state.last_pane_surface().unwrap().surface_revision, 2); } } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 5051a46b..025983a3 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -245,11 +245,6 @@ impl TerminalRuntime { self.0.agent_detection_reset_notify_for_test() } - #[cfg(test)] - pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { - self.0.agent_detection_enabled_for_test() - } - pub fn set_full_lifecycle_authority_active(&self, active: bool) { self.0.set_full_lifecycle_authority_active(active); } @@ -283,23 +278,20 @@ impl TerminalRuntime { self.0.scroll_metrics() } - pub(crate) fn search_text_matches( + pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, - ) -> Vec { - self.0.search_text_matches(query, case_sensitive) - } - - pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { - self.0.text_match_is_current(text_match) - } - - pub(crate) fn text_matches_are_current( - &self, - text_matches: &[crate::pane::TerminalTextMatch], - ) -> Vec { - self.0.text_matches_are_current(text_matches) + direction: crate::pane::TerminalSearchDirection, + cursor: crate::pane::TerminalTextPoint, + previous: Option<( + crate::pane::TerminalTextPoint, + crate::pane::TerminalTextPoint, + )>, + limit: usize, + ) -> crate::pane::TerminalSearchWindow { + self.0 + .search_text_window(query, case_sensitive, direction, cursor, previous, limit) } pub(crate) fn word_motion_target( @@ -311,17 +303,16 @@ impl TerminalRuntime { self.0.word_motion_target(row, col, motion) } - /// Collects the complete terminal input-mode snapshot. - /// - /// This performs multiple terminal queries. Keep it out of render/layout - /// and pane-scaled loops; add a narrow accessor when one fact is needed. - #[cfg(test)] - pub fn input_state(&self) -> Option { - self.0.input_state() + pub(crate) fn terminal_dimensions(&self) -> Option<(u16, u16)> { + self.0.terminal_dimensions() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.0.keyboard_report_all_requested() + pub(crate) fn paragraph_motion_target( + &self, + row: u32, + direction: i8, + ) -> Option { + self.0.paragraph_motion_target(row, direction) } pub fn bracketed_paste_enabled(&self) -> bool { @@ -446,12 +437,12 @@ impl TerminalRuntime { self.0.keyboard_protocol() } - pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { - self.0.encode_terminal_key(key) + pub fn modify_other_keys_level(&self) -> u8 { + self.0.modify_other_keys_level() } - pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { - self.0.send_bytes(bytes).await + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { + self.0.encode_terminal_key(key) } pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { @@ -462,10 +453,6 @@ impl TerminalRuntime { self.0.send_bytes_after(bytes, delay); } - pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { - self.0.send_paste(text).await - } - pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { self.0.try_send_paste(text) } diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index c9f08ceb..b38d251b 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -1,7 +1,5 @@ use std::io::{self, Write}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; #[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"; @@ -53,18 +51,14 @@ pub(crate) fn set_host_kitty_keyboard_report_all( let mut flags = crate::input::ime_compatible_keyboard_enhancement_flags(); if report_all_keys { flags |= crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; - // Report-all turns IME commits into CSI-u key events in terminals such - // as Ghostty. Ask the terminal to carry the committed text with them. flags = crossterm::event::KeyboardEnhancementFlags::from_bits_retain( flags.bits() | 0b0001_0000, ); } - // Older iTerm2 releases clear the keyboard stack on SET, so a later pop - // cannot restore the host state. Replace only Herdr's top entry instead. crossterm::execute!( writer, - PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags(flags) + crossterm::event::PopKeyboardEnhancementFlags, + crossterm::event::PushKeyboardEnhancementFlags(flags) ) } @@ -76,10 +70,64 @@ pub(crate) fn set_host_kitty_keyboard_report_all( Ok(()) } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DirectHostKeyboardState { + kitty_flags: Option, + modify_other_keys_level: u8, +} + +#[cfg(not(windows))] +pub(crate) fn set_direct_host_keyboard_protocol( + writer: &mut W, + active: &mut DirectHostKeyboardState, + next_flags: u16, + next_modify_other_keys_level: u8, +) -> io::Result<()> { + let next_kitty_flags = (next_flags != 0).then_some(next_flags); + if active.kitty_flags == next_kitty_flags + && active.modify_other_keys_level == next_modify_other_keys_level + { + return Ok(()); + } + + if active.kitty_flags != next_kitty_flags { + if active.kitty_flags.is_some() { + writer.write_all(b"\x1b[<1u")?; + } + if next_flags != 0 { + write!(writer, "\x1b[>{next_flags}u")?; + } + } + if active.modify_other_keys_level != next_modify_other_keys_level { + write!(writer, "\x1b[>4;{next_modify_other_keys_level}m")?; + } + writer.flush()?; + *active = DirectHostKeyboardState { + kitty_flags: next_kitty_flags, + modify_other_keys_level: next_modify_other_keys_level, + }; + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn set_direct_host_keyboard_protocol( + _writer: &mut W, + active: &mut DirectHostKeyboardState, + next_flags: u16, + next_modify_other_keys_level: u8, +) -> io::Result<()> { + *active = DirectHostKeyboardState { + kitty_flags: (next_flags != 0).then_some(next_flags), + modify_other_keys_level: next_modify_other_keys_level, + }; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(not(windows))] #[test] fn host_keyboard_report_all_replaces_the_current_herdr_stack_entry() { let mut output = Vec::new(); @@ -90,6 +138,48 @@ mod tests { assert_eq!(output, b"\x1b[<1u\x1b[>31u\x1b[<1u\x1b[>7u"); } + #[cfg(not(windows))] + #[test] + fn direct_keyboard_protocol_owns_exactly_one_stack_entry_and_modify_other_keys() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 3, 0).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 15, 2).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert_eq!( + output, + b"\x1b[>3u\x1b[<1u\x1b[>15u\x1b[>4;2m\x1b[<1u\x1b[>4;0m" + ); + assert_eq!(active, DirectHostKeyboardState::default()); + } + + #[cfg(not(windows))] + #[test] + fn direct_modify_other_keys_works_without_kitty_flags() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 1).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 2).unwrap(); + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert_eq!(output, b"\x1b[>4;1m\x1b[>4;2m\x1b[>4;0m"); + assert_eq!(active, DirectHostKeyboardState::default()); + } + + #[test] + fn direct_legacy_keyboard_mode_does_not_pop_the_host_stack() { + let mut output = Vec::new(); + let mut active = DirectHostKeyboardState::default(); + + set_direct_host_keyboard_protocol(&mut output, &mut active, 0, 0).unwrap(); + + assert!(output.is_empty()); + assert_eq!(active, DirectHostKeyboardState::default()); + } + #[test] fn clears_all_known_host_mouse_modes() { let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap(); diff --git a/src/ui.rs b/src/ui.rs index 8adc31ef..7a488374 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,118 +1,53 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::Span, - Frame, -}; +use ratatui::layout::Rect; -mod dialogs; -mod keybind_help; -mod menus; -mod mobile; -mod navigator; mod onboarding; mod panes; mod release_notes; mod scrollbar; -mod settings; mod sidebar; mod status; mod tab_surface; -mod tabs; mod text; mod widgets; -use self::dialogs::{ - render_confirm_close_overlay, render_new_linked_worktree_overlay, - render_open_existing_worktree_overlay, render_remove_worktree_overlay, render_rename_overlay, +pub(crate) use self::onboarding::{ + onboarding_welcome_continue_rect, ONBOARDING_DESCRIPTION, ONBOARDING_HELP_LABEL, + ONBOARDING_HELP_SUFFIX, ONBOARDING_NEXT, ONBOARDING_PREFIX_LABEL, ONBOARDING_PREFIX_SUFFIX, + ONBOARDING_SUBTITLE, ONBOARDING_TITLE, }; -use self::keybind_help::render_keybind_help_overlay; -use self::menus::{ - render_context_menu, render_copy_mode_overlay, render_global_launcher_menu, - render_navigate_overlay, render_prefix_overlay, render_resize_overlay, -}; -use self::mobile::{ - compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, - mobile_toast_banner_rect, render_mobile_header, render_mobile_panel, - render_mobile_toast_banner, -}; -use self::navigator::render_navigator_overlay; -pub(crate) use self::onboarding::onboarding_welcome_continue_rect; -use self::onboarding::render_onboarding_overlay; +#[cfg(all(test, unix))] pub(crate) use self::panes::popup_pane_rects; -use self::panes::{render_empty, render_popup_pane, resize_popup_pane}; +use self::panes::resize_popup_pane; +pub(crate) use self::panes::{ + apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back, render_selection_highlight, +}; pub(crate) use self::release_notes::{ - product_announcement_display_lines, release_notes_close_button_rect, - release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE, - RELEASE_NOTES_MODAL_SIZE, + product_announcement_display_lines, product_announcement_scroll_metrics, + release_notes_close_button_rect, release_notes_display_lines, release_notes_scroll_metrics, + PRODUCT_ANNOUNCEMENT_MODAL_SIZE, RELEASE_NOTES_MODAL_SIZE, }; -use self::release_notes::{render_product_announcement_overlay, render_release_notes_overlay}; pub(crate) use self::scrollbar::{ - pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row, - scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar, + release_notes_scrollbar_rect, render_pane_scrollbar_buffer, render_scrollbar_buffer, + scrollbar_offset_from_drag_row, scrollbar_offset_from_row, scrollbar_thumb, + scrollbar_thumb_grab_offset, }; -use self::settings::render_settings_overlay; -#[cfg(test)] -pub(crate) use self::sidebar::workspace_drop_indicator_row; -use self::sidebar::{render_sidebar, render_sidebar_collapsed}; -use self::status::{ - copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification, - toast_notification_rect, +pub(crate) use self::sidebar::{ + agent_panel_entries_from, expanded_sidebar_sections, resolved_token_spans, sidebar_agent_rows, + sidebar_section_divider_rect, sidebar_space_rows, AgentPanelEntry, AgentTokenContext, + ResolvedToken, ResolvedTokenKind, SpaceTokenContext, }; +use self::status::copy_feedback_rect; +pub(crate) use self::status::{render_config_diagnostic_buffer, render_copy_feedback_buffer}; pub(crate) use self::tab_surface::{ - compute_tab_surface, render_tab_surface, resize_tab_surface, TabSurfaceLayout, -}; -use self::tabs::render_tab_bar; -pub(crate) use self::{ - dialogs::{ - confirm_close_button_rects, confirm_close_popup_rect, new_linked_worktree_button_rects, - new_linked_worktree_inner_rect, open_existing_worktree_button_rects, - open_existing_worktree_inner_rect, open_existing_worktree_max_visible_rows, - open_existing_worktree_visible_start, remove_worktree_button_rects, - remove_worktree_popup_rect, rename_button_rects, - }, - settings::{ - settings_button_rects, settings_popup_height, settings_show_primary_action, - SETTINGS_POPUP_WIDTH, - }, - sidebar::{ - agent_entry_gap, agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries, - agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, - agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections, - collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, - expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect, - workspace_drop_slots, workspace_group_chevron_rect, workspace_list_entries, - workspace_list_entries_expanded, workspace_list_rect, workspace_list_scroll_metrics, - workspace_list_scrollbar_rect, workspace_parent_group_state, AgentPanelEntry, - WorkspaceListEntry, - }, + compute_tab_surface, render_tab_surface, resize_tab_surface, tab_surface_cursor, + tab_surface_hyperlinks, TabSurfaceLayout, TabSurfaceView, }; +pub(crate) use self::text::truncate_end; +pub(crate) use self::widgets::{centered_popup_rect, modal_stack_areas}; -pub(crate) use self::{ - keybind_help::keybind_help_lines, - mobile::{ - mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, - mobile_switcher_workspace_doc_range, MobileSwitcherTarget, - }, - panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back}, - tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView}, - tabs::{compute_tab_bar_view, tab_bar_content_area}, - widgets::{centered_popup_rect, modal_stack_areas}, -}; -use crate::app::state::ViewLayout; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::terminal::TerminalRuntimeRegistry; -const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator - -/// Compute view geometry and reconcile pane sizes. -/// Called before render to separate mutation from drawing. -#[cfg_attr(not(test), allow(dead_code))] -pub fn compute_view(app: &mut AppState, area: Rect) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - compute_view_with_runtime_registry(app, &terminal_runtimes, area); -} - pub fn compute_view_with_runtime_registry( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -136,11 +71,6 @@ pub fn compute_view_with_cell_size( compute_view_internal(app, terminal_runtimes, area, true, cell_size); } -/// Compute view geometry for a client-sized render without resizing pane runtimes. -/// -/// This is used by the headless server when a non-foreground client needs its -/// own frame size while the shared pane runtimes stay pinned to the foreground -/// client. pub(crate) fn compute_view_without_resizing_panes( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -155,63 +85,6 @@ pub(crate) fn compute_view_without_resizing_panes( ); } -fn resize_background_tab_panes_to_area( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - terminal_area: Rect, - cell_size: crate::kitty_graphics::HostCellSize, -) { - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - for (tab_idx, tab) in ws.tabs.iter().enumerate() { - if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { - continue; - } - resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size); - } - } -} - -fn resize_background_tab_panes_for_desktop( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - main_area: Rect, - cell_size: crate::kitty_graphics::HostCellSize, -) { - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - let (_, terminal_area) = desktop_tab_bar_and_terminal_area(app, ws, main_area); - for (tab_idx, tab) in ws.tabs.iter().enumerate() { - if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { - continue; - } - resize_tab_surface(app, terminal_runtimes, tab, terminal_area, cell_size); - } - } -} - -fn desktop_tab_bar_and_terminal_area( - app: &AppState, - ws: &crate::workspace::Workspace, - main_area: Rect, -) -> (Rect, Rect) { - let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1; - if !hide_single_tab_bar && main_area.height > 1 { - match app.tab_bar_position { - crate::config::TabBarPositionConfig::Top => { - let [tab_bar_rect, terminal_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); - (tab_bar_rect, terminal_area) - } - crate::config::TabBarPositionConfig::Bottom => { - let [terminal_area, tab_bar_rect] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(main_area); - (tab_bar_rect, terminal_area) - } - } - } else { - (Rect::default(), main_area) - } -} - fn compute_view_internal( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -219,335 +92,37 @@ fn compute_view_internal( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { - if is_mobile_width(area, app.mobile_width_threshold) { - compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size); - return; - } + let TabSurfaceLayout { pane_infos, .. } = + compute_tab_surface(app, terminal_runtimes, area, resize_panes, cell_size); - let sidebar_w = if app.sidebar_collapsed { - match app.sidebar_collapsed_mode { - crate::config::SidebarCollapsedModeConfig::Compact => COLLAPSED_WIDTH, - crate::config::SidebarCollapsedModeConfig::Hidden => 0, - } - } else { - app.sidebar_width - .clamp(app.sidebar_min_width, app.sidebar_max_width) - }; - - let [sidebar_area, main_area] = - Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); - - let (tab_bar_rect, terminal_area) = app - .active - .and_then(|i| app.workspaces.get(i)) - .map(|ws| desktop_tab_bar_and_terminal_area(app, ws, main_area)) - .unwrap_or((Rect::default(), main_area)); - - if !app.sidebar_collapsed { - app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll); - let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split); - let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom; - app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll); - } else { - app.workspace_scroll = app - .workspace_scroll - .min(app.workspaces.len().saturating_sub(1)); - app.agent_panel_scroll = 0; - } - - let workspace_card_areas = if app.sidebar_collapsed { - Vec::new() - } else { - compute_workspace_card_areas(app, sidebar_area) - }; - - let tab_bar_view = app - .active - .and_then(|ws_idx| app.workspaces.get(ws_idx)) - .map(|ws| { - compute_tab_bar_view( - ws, - tab_bar_content_area(app, tab_bar_rect), - app.tab_scroll, - app.tab_scroll_follow_active, - app.mouse_capture, - ) - }) - .unwrap_or_default(); - app.tab_scroll = tab_bar_view.scroll; - - let TabSurfaceLayout { - pane_infos, - split_borders, - } = compute_tab_surface( - app, - terminal_runtimes, - terminal_area, - resize_panes, - cell_size, - ); if resize_panes { - resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size); - resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); + resize_background_tab_panes(app, terminal_runtimes, area, cell_size); + resize_popup_pane(app, terminal_runtimes, area, cell_size); } - let toast_hit_area = app - .toast - .as_ref() - .map(|toast| { - toast_notification_rect( - area, - toast, - app.config_diagnostic.is_some(), - toast.position.unwrap_or(app.toast_config.herdr.position), - ) - }) - .unwrap_or_default(); - app.view = crate::app::ViewState { - layout: ViewLayout::Desktop, - sidebar_rect: sidebar_area, - workspace_card_areas, - tab_bar_rect, - tab_hit_areas: tab_bar_view.tab_hit_areas, - tab_scroll_left_hit_area: tab_bar_view.scroll_left_hit_area, - tab_scroll_right_hit_area: tab_bar_view.scroll_right_hit_area, - new_tab_hit_area: tab_bar_view.new_tab_hit_area, - terminal_area, - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area, + terminal_area: area, pane_infos, - split_borders, }; - app.sync_copy_mode_search_geometry(); } -fn compute_mobile_view( - app: &mut AppState, +fn resize_background_tab_panes( + app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, - resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { - let header_h = area.height.min(2); - let (header_rect, terminal_area) = if area.height > header_h { - let [header_rect, terminal_area] = - Layout::vertical([Constraint::Length(header_h), Constraint::Min(1)]).areas(area); - (header_rect, terminal_area) - } else { - (area, Rect::default()) - }; - - if app.mode == Mode::Navigate { - let switcher_viewport_h = area.height.saturating_sub(header_h + 1); - let max_scroll = mobile_switcher_max_scroll_for_height(app, switcher_viewport_h); - app.mobile_switcher_scroll = app.mobile_switcher_scroll.min(max_scroll); - } - - let TabSurfaceLayout { - pane_infos, - split_borders, - } = compute_tab_surface( - app, - terminal_runtimes, - terminal_area, - resize_panes, - cell_size, - ); - if resize_panes { - resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size); - resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); - } - let header_hits = compute_mobile_header_hit_areas(app, header_rect); - - let toast_hit_area = app - .toast - .as_ref() - .map(|_| mobile_toast_banner_rect(area, app.config_diagnostic.is_some())) - .unwrap_or_default(); - - app.view = crate::app::ViewState { - layout: ViewLayout::Mobile, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), - terminal_area, - mobile_header_rect: header_rect, - mobile_menu_hit_area: header_hits.menu, - toast_hit_area, - pane_infos, - split_borders, - }; - app.sync_copy_mode_search_geometry(); -} - -/// Render the UI — reads AppState but does not mutate it. -#[cfg_attr(not(test), allow(dead_code))] -pub fn render(app: &AppState, frame: &mut Frame) { - let terminal_runtimes = TerminalRuntimeRegistry::new(); - render_with_runtime_registry(app, &terminal_runtimes, frame); -} - -pub fn render_with_runtime_registry( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let tab_bar_area = app.view.tab_bar_rect; - let terminal_area = app.view.terminal_area; - - render_navigation_chrome(app, terminal_runtimes, frame); - if app.view.layout != ViewLayout::Mobile { - render_tab_bar(app, frame, tab_bar_area); - } - if app - .active - .and_then(|ws_idx| app.workspaces.get(ws_idx)) - .is_some() - { - render_tab_surface(app, terminal_runtimes, app.view.tab_surface(), frame); - } else { - render_empty(app, frame, terminal_area); - } - - // Ambient notifications sit above panes, but below interactive overlays. - render_notifications(app, frame, terminal_area); - render_popup_pane(app, terminal_runtimes, frame, terminal_area); - - let mode_bar_area = if app.view.layout == ViewLayout::Desktop - && app.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && tab_bar_area.height > 0 - { - tab_bar_area - } else { - terminal_area - }; - - match app.mode { - Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), - Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()), - Mode::ProductAnnouncement => render_product_announcement_overlay(app, frame, frame.area()), - Mode::Navigate if app.view.layout == ViewLayout::Mobile => { - render_mobile_panel(app, terminal_runtimes, frame, frame.area()) - } - Mode::Navigate => render_navigate_overlay(app, frame, mode_bar_area), - Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area), - Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area), - Mode::Resize => render_resize_overlay(app, frame, mode_bar_area), - Mode::ConfirmClose => { - render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area) - } - Mode::ContextMenu => { - render_context_menu(app, frame); - } - Mode::Settings => render_settings_overlay(app, frame, frame.area()), - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - render_rename_overlay(app, frame, frame.area()) - } - Mode::NewLinkedWorktree => render_new_linked_worktree_overlay(app, frame, frame.area()), - Mode::OpenExistingWorktree => { - render_open_existing_worktree_overlay(app, frame, frame.area()) - } - Mode::ConfirmRemoveWorktree => render_remove_worktree_overlay(app, frame, frame.area()), - Mode::GlobalMenu => render_global_launcher_menu(app, frame), - Mode::KeybindHelp => render_keybind_help_overlay(app, frame), - Mode::Navigator => render_navigator_overlay(app, terminal_runtimes, frame), - Mode::Terminal => {} - } -} - -fn render_navigation_chrome( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - if app.view.layout == ViewLayout::Mobile { - render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect); - } else if app.view.sidebar_rect.width > 0 { - if app.sidebar_collapsed { - render_sidebar_collapsed(app, frame, app.view.sidebar_rect); - } else { - render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect); + for (workspace_index, workspace) in app.workspaces.iter().enumerate() { + for (tab_index, tab) in workspace.tabs.iter().enumerate() { + if app.active == Some(workspace_index) && tab_index == workspace.active_tab_index() { + continue; + } + resize_tab_surface(app, terminal_runtimes, tab, area, cell_size); } } } -fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) { - let has_config_diagnostic = app.config_diagnostic.is_some(); - if let Some(message) = &app.config_diagnostic { - let diagnostic_area = if app.view.layout == ViewLayout::Mobile { - terminal_area - } else { - frame.area() - }; - render_config_diagnostic(frame, diagnostic_area, message, &app.palette); - } - let mut copy_feedback_offset = u16::from(has_config_diagnostic); - let mut toast_rect = None; - if let Some(toast) = &app.toast { - if app.view.layout == ViewLayout::Mobile { - render_mobile_toast_banner( - frame, - frame.area(), - toast, - has_config_diagnostic, - &app.palette, - ); - } else { - render_toast_notification( - frame, - frame.area(), - toast, - has_config_diagnostic, - toast.position.unwrap_or(app.toast_config.herdr.position), - &app.palette, - ); - toast_rect = Some(toast_notification_rect( - frame.area(), - toast, - has_config_diagnostic, - toast.position.unwrap_or(app.toast_config.herdr.position), - )); - } - if app.view.layout == ViewLayout::Mobile { - toast_rect = Some(mobile_toast_banner_rect( - frame.area(), - has_config_diagnostic, - )); - } - } - if let Some(feedback) = &app.copy_feedback { - let area = if app.view.layout == ViewLayout::Mobile { - frame.area() - } else { - terminal_area - }; - if let Some(toast_rect) = toast_rect { - copy_feedback_offset = copy_feedback_offset_for_toast( - area, - feedback, - copy_feedback_offset, - app.toast_config.clipboard.position, - toast_rect, - ); - } - render_copy_feedback( - frame, - area, - feedback, - copy_feedback_offset, - app.toast_config.clipboard.position, - &app.palette, - ); - } -} - -fn copy_feedback_offset_for_toast( +pub(crate) fn copy_feedback_offset_for_toast( area: Rect, feedback: &crate::app::state::CopyFeedback, base_offset: u16, @@ -555,1007 +130,16 @@ fn copy_feedback_offset_for_toast( toast_rect: Rect, ) -> u16 { let feedback_rect = copy_feedback_rect(area, feedback, base_offset, position); - if rects_overlap(feedback_rect, toast_rect) { + if rectangles_overlap(feedback_rect, toast_rect) { base_offset.saturating_add(toast_rect.height) } else { base_offset } } -fn rects_overlap(a: Rect, b: Rect) -> bool { - a.x < b.x.saturating_add(b.width) - && b.x < a.x.saturating_add(a.width) - && a.y < b.y.saturating_add(b.height) - && b.y < a.y.saturating_add(a.height) -} - -fn dim_background(frame: &mut Frame, area: Rect) { - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } -} - -/// Floating overlay for navigate mode — appears at bottom of terminal area. -fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec> { - let mut spans = Vec::new(); - spans.push(Span::raw(" ")); - for (i, (k, desc)) in items.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", dim_style)); - } - spans.push(Span::styled(k.to_string(), key_style)); - spans.push(Span::styled(format!(" {desc}"), dim_style)); - } - spans -} - -#[cfg(test)] -mod tests { - use super::keybind_help::keybind_help_groups; - use super::scrollbar::scrollbar_thumb; - use super::*; - use crate::{app::state::ViewLayout, layout::PaneInfo, workspace::Workspace}; - use ratatui::style::Color; - use ratatui::{backend::TestBackend, Terminal}; - - #[test] - fn copy_feedback_offset_only_increases_when_toast_rect_overlaps() { - let area = Rect::new(0, 0, 80, 24); - let feedback = crate::app::state::CopyFeedback { - message: "copied to clipboard".into(), - }; - let toast = crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "workspace · 1".into(), - position: None, - target: None, - }; - - let bottom_right_toast = toast_notification_rect( - area, - &toast, - false, - crate::config::ToastHerdrPosition::BottomRight, - ); - assert_eq!( - copy_feedback_offset_for_toast( - area, - &feedback, - 0, - crate::config::ToastClipboardPosition::TopCenter, - bottom_right_toast, - ), - 0 - ); - - let bottom_center_toast = Rect::new(28, 21, 24, 3); - assert_eq!( - copy_feedback_offset_for_toast( - area, - &feedback, - 0, - crate::config::ToastClipboardPosition::BottomCenter, - bottom_center_toast, - ), - bottom_center_toast.height - ); - } - - #[test] - fn workspace_creation_dialog_renders_new_workspace_title() { - let mut app = crate::app::state::AppState::test_new(); - app.mode = Mode::RenameWorkspace; - app.pending_workspace_create_cwd = Some("/tmp/project".into()); - app.name_input = "project".into(); - - let area = Rect::new(0, 0, 80, 20); - compute_view(&mut app, area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let screen = (0..area.height) - .map(|row| buffer_row_text(terminal.backend().buffer(), area, row)) - .collect::>() - .join("\n"); - - assert!(screen.contains("new workspace"), "{screen}"); - assert!(screen.contains("project"), "{screen}"); - } - - #[tokio::test] - async fn focused_pane_cursor_wins_during_terminal_render() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - - ws.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"), - ); - ws.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"), - ); - ws.tabs[0].layout.focus_pane(first_pane); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - let focused = app - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("focused pane info"); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - - terminal - .backend_mut() - .assert_cursor_position((focused.inner_rect.x + 4, focused.inner_rect.y)); - } - - #[test] - fn mobile_width_uses_header_and_full_width_terminal() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.sidebar_rect, Rect::default()); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!(app.view.mobile_menu_hit_area.height, 2); - assert_eq!( - app.view.mobile_menu_hit_area.x + app.view.mobile_menu_hit_area.width, - 44 - ); - } - - #[test] - fn mobile_config_diagnostic_keeps_command_visible() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.config_diagnostic = Some("config.toml:100:10; herdr config check".into()); - - let area = Rect::new(0, 0, 44, 20); - compute_view(&mut app, area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let row = buffer_row_text(terminal.backend().buffer(), area, app.view.terminal_area.y); - - assert!(row.contains("config.toml:100:10"), "{row}"); - assert!(row.contains("herdr config check"), "{row}"); - } - - #[test] - fn desktop_toast_hit_area_uses_full_frame_not_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft; - app.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "one".into(), - position: None, - target: None, - }); - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.layout, ViewLayout::Desktop); - assert!(app.view.terminal_area.x > 0); - assert_eq!(app.view.toast_hit_area.x, 0); - assert_eq!(app.view.toast_hit_area.y, 0); - } - - #[test] - fn desktop_toast_hit_area_still_offsets_for_config_diagnostic() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.config_diagnostic = Some("config warning".into()); - app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft; - app.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "one".into(), - position: None, - target: None, - }); - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.toast_hit_area.x, 0); - assert_eq!(app.view.toast_hit_area.y, 1); - } - - #[test] - fn configured_mobile_width_threshold_controls_layout_switch() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.layout, ViewLayout::Desktop); - - app.mobile_width_threshold = 90; - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 80, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18)); - } - - #[test] - fn desktop_tab_bar_position_controls_geometry_and_mode_bar_placement() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Prefix; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19)); - - app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 19)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 19, 54, 1)); - assert!(app.view.tab_hit_areas.iter().all(|rect| rect.y == 19)); - assert_eq!(app.view.new_tab_hit_area.y, 19); - - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let mode_row = buffer_row_text( - terminal.backend().buffer(), - app.view.tab_bar_rect, - app.view.tab_bar_rect.y, - ); - assert!(mode_row.contains("PREFIX"), "{mode_row}"); - } - - #[test] - fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - let single_tab_terminal_area = app.view.terminal_area; - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(single_tab_terminal_area, Rect::new(26, 0, 54, 20)); - assert!(app.view.tab_hit_areas.is_empty()); - assert_eq!(app.view.new_tab_hit_area, Rect::default()); - - app.workspaces[0].test_add_tab(Some("logs")); - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19)); - assert_eq!(app.view.tab_hit_areas.len(), 2); - assert!(app.view.tab_hit_areas.iter().all(|rect| rect.width > 0)); - assert!(app.view.new_tab_hit_area.width > 0); - - assert!(app.workspaces[0].close_tab(1)); - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.terminal_area, single_tab_terminal_area); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert!(app.view.tab_hit_areas.is_empty()); - assert_eq!(app.view.new_tab_hit_area, Rect::default()); - } - - #[test] - fn bottom_tab_bar_still_hides_when_single_tab() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Prefix; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::default()); - assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 20)); - - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let mode_row = buffer_row_text( - terminal.backend().buffer(), - app.view.terminal_area, - app.view.terminal_area.y + app.view.terminal_area.height - 1, - ); - assert!(mode_row.contains("PREFIX"), "{mode_row}"); - } - - #[tokio::test] - async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() { - let mut app = crate::app::state::AppState::test_new(); - app.hide_tab_bar_when_single_tab = true; - - let mut one_tab_workspace = Workspace::test_new("one"); - let one_tab_pane = one_tab_workspace.tabs[0].root_pane; - let one_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - one_tab_workspace.tabs[0] - .runtimes - .insert(one_tab_pane, one_tab_runtime); - - let mut two_tab_workspace = Workspace::test_new("two"); - let background_tab = two_tab_workspace.test_add_tab(Some("logs")); - let two_tab_pane = two_tab_workspace.tabs[background_tab].root_pane; - let two_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - two_tab_workspace.tabs[background_tab] - .runtimes - .insert(two_tab_pane, two_tab_runtime); - - app.workspaces = vec![one_tab_workspace, two_tab_workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let one_tab_size = app.workspaces[0].tabs[0].runtimes[&one_tab_pane].current_size(); - let two_tab_size = - app.workspaces[1].tabs[background_tab].runtimes[&two_tab_pane].current_size(); - assert_eq!(one_tab_size, (20, 53)); - assert_eq!(two_tab_size, (19, 53)); - } - - #[tokio::test] - async fn mobile_background_tabs_use_mobile_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - - let mut workspace = Workspace::test_new("mobile"); - let background_tab = workspace.test_add_tab(Some("logs")); - let background_pane = workspace.tabs[background_tab].root_pane; - let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); - workspace.tabs[background_tab] - .runtimes - .insert(background_pane, runtime); - - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!(app.view.layout, ViewLayout::Mobile); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!( - app.workspaces[0].tabs[background_tab].runtimes[&background_pane].current_size(), - (18, 43) - ); - } - - #[test] - fn product_announcement_renders_above_config_diagnostic() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::ProductAnnouncement; - app.product_announcement = Some(crate::app::state::ProductAnnouncementState { - version: "0.6.0".into(), - id: "keybinding-v2".into(), - title: "Keybinding syntax changed".into(), - body: "### Update\n- Body".into(), - scroll: 0, - preview: false, - }); - app.config_diagnostic = Some( - "unsafe direct keybinding: keys.new_workspace = \"n\"\nunsafe direct keybinding: keys.new_tab = \"c\"" - .into(), - ); - - let area = Rect::new(0, 0, 44, 20); - compute_view(&mut app, area); - - let backend = TestBackend::new(area.width, area.height); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let popup = centered_popup_rect( - area, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - ) - .expect("announcement popup"); - let title_row = popup.y + 1; - let row = buffer_row_text(buffer, Rect::new(0, title_row, area.width, 1), title_row); - - assert!(row.contains("Keybinding syntax changed")); - assert!(!row.contains("config warning")); - } - - #[test] - fn compute_view_clamps_sidebar_width_to_configured_max() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.sidebar_max_width = 30; - app.sidebar_width = 999; - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.sidebar_rect.width, 30); - } - - #[test] - fn compute_view_clamps_sidebar_width_to_configured_min() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.sidebar_min_width = 22; - app.sidebar_width = 5; - - compute_view(&mut app, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.view.sidebar_rect.width, 22); - } - - #[test] - fn hidden_collapsed_sidebar_uses_full_width_terminal_area() { - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_collapsed = true; - app.sidebar_collapsed_mode = crate::config::SidebarCollapsedModeConfig::Hidden; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 0, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(0, 0, 80, 1)); - assert_eq!(app.view.terminal_area, Rect::new(0, 1, 80, 19)); - assert!(app.view.workspace_card_areas.is_empty()); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - } - - #[test] - fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() { - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_collapsed = true; - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(1); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let (ws_area, _, _) = collapsed_sidebar_sections(app.view.sidebar_rect); - let active_row = ws_area.y + 1; - let active_style = buffer[(ws_area.x, active_row)].style(); - - assert_eq!(active_style.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("one"); - let repo = temp_git_repo("main"); - ws.identity_cwd = repo.clone(); - let root_pane = ws.tabs[0].root_pane; - ws.refresh_git_ahead_behind(); - - app.workspaces = vec![ws]; - app.ensure_test_terminals(); - let root_terminal_id = app.workspaces[0].tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&root_terminal_id).unwrap().cwd = repo.clone(); - app.selected = 0; - app.mode = Mode::Navigate; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let card = app.view.workspace_card_areas[0].rect; - let line1 = buffer_row_text(buffer, card, card.y); - let line2 = buffer_row_text(buffer, card, card.y + 1); - - assert!(line1.starts_with(" · one")); - assert!(!line1.contains("1 one")); - assert_eq!(line2, " main"); - - std::fs::remove_dir_all(repo).ok(); - } - - #[test] - fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let custom_tab = ws.test_add_tab(Some("logs")); - ws.switch_tab(custom_tab); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let auto_rect = app.view.tab_hit_areas[0]; - let custom_rect = app.view.tab_hit_areas[1]; - let auto_style = buffer[(auto_rect.x + 1, auto_rect.y)].style(); - let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style(); - - assert_eq!(auto_style.fg, Some(app.palette.overlay0)); - assert!(auto_style.add_modifier.contains(Modifier::DIM)); - assert_eq!(custom_style.fg, Some(app.palette.panel_bg)); - assert!(custom_style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn tab_bar_uses_surface_dim_when_panel_background_resets() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let custom_tab = ws.test_add_tab(Some("logs")); - ws.switch_tab(custom_tab); - - app.palette.panel_bg = Color::Reset; - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - let buffer = terminal.backend().buffer(); - - let custom_rect = app.view.tab_hit_areas[1]; - let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style(); - - assert_eq!(custom_style.bg, Some(app.palette.accent)); - assert_eq!(custom_style.fg, Some(app.palette.surface_dim)); - assert!(custom_style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn new_tab_button_tracks_rightmost_tab_when_tabs_fit() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - - compute_view(&mut app, Rect::new(0, 0, 80, 20)); - - let last_visible = app - .view - .tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .copied() - .expect("last visible tab"); - - assert_eq!( - app.view.new_tab_hit_area.x, - last_visible.x + last_visible.width - ); - } - - #[test] - fn tab_bar_shows_scroll_controls_when_tabs_overflow() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - for name in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"] { - ws.test_add_tab(Some(name)); - } - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.tab_scroll_follow_active = false; - app.tab_scroll = 2; - - compute_view(&mut app, Rect::new(0, 0, 65, 20)); - - assert!(app.view.tab_scroll_left_hit_area.width > 0); - assert!(app.view.tab_scroll_right_hit_area.width > 0); - assert_eq!(app.view.tab_hit_areas[0].width, 0); - assert_eq!(app.view.tab_hit_areas[1].width, 0); - assert!(app.view.tab_hit_areas[2].width > 0); - assert!(app.view.new_tab_hit_area.width > 0); - - let last_visible = app - .view - .tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .copied() - .expect("last visible tab"); - - assert_eq!( - app.view.tab_scroll_right_hit_area.x, - last_visible.x + last_visible.width - ); - assert_eq!( - app.view.new_tab_hit_area.x, - app.view.tab_scroll_right_hit_area.x + app.view.tab_scroll_right_hit_area.width - ); - } - - #[test] - fn tab_bar_clamps_manual_scroll_at_last_visible_tab() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.tab_scroll_follow_active = false; - app.tab_scroll = usize::MAX; - - compute_view(&mut app, Rect::new(0, 0, 65, 20)); - - let last_idx = app.workspaces[0].tabs.len() - 1; - assert!(app.view.tab_hit_areas[last_idx].width > 0); - let clamped_scroll = app.tab_scroll; - - app.scroll_tabs_right(); - - assert_eq!(app.tab_scroll, clamped_scroll); - assert!(app.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn pane_scrollbar_rect_uses_reserved_rightmost_column() { - let info = PaneInfo { - id: crate::layout::PaneId::from_raw(1), - rect: Rect::new(0, 0, 12, 8), - inner_rect: Rect::new(1, 1, 9, 6), - scrollbar_rect: Some(Rect::new(10, 1, 1, 6)), - borders: ratatui::widgets::Borders::ALL, - is_focused: true, - }; - - assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); - } - - #[tokio::test] - async fn compute_view_reserves_terminal_column_when_pane_scrollbar_is_visible() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 12, - 4, - 4096, - b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n", - ), - ); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - - compute_view(&mut app, Rect::new(0, 0, 40, 12)); - - let info = app.view.pane_infos.first().expect("pane info"); - assert_eq!(info.inner_rect.width + 1, app.view.terminal_area.width); - assert_eq!( - info.scrollbar_rect, - Some(Rect::new( - info.inner_rect.x + info.inner_rect.width, - info.inner_rect.y, - 1, - info.inner_rect.height, - )) - ); - } - - #[test] - fn scrollbar_stays_hidden_without_scrollback() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 0, - viewport_rows: 5, - }; - - assert!(!should_show_scrollbar(metrics)); - } - - #[test] - fn scrollbar_shows_with_scrollback() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - - assert!(should_show_scrollbar(metrics)); - } - - #[test] - fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 5); - - let thumb = scrollbar_thumb(metrics, track).expect("thumb"); - assert_eq!(thumb.top + thumb.len, track.y + track.height); - } - - #[test] - fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 5); - - assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20); - assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); - assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0); - } - - #[test] - fn dragging_from_current_thumb_row_preserves_offset() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 7, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - let track = Rect::new(9, 4, 1, 8); - let thumb = scrollbar_thumb(metrics, track).expect("thumb"); - let row = thumb.top + thumb.len / 2; - let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab"); - - assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7); - } - - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - fn temp_git_repo(branch: &str) -> std::path::PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("unix time") - .as_nanos(); - let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}")); - std::fs::create_dir_all(root.join(".git")).expect("create .git dir"); - std::fs::write( - root.join(".git/HEAD"), - format!("ref: refs/heads/{branch}\n"), - ) - .expect("write HEAD"); - root - } - - #[test] - fn prefix_mode_renders_prefix_indicator() { - let mut app = crate::app::state::AppState::test_new(); - app.mode = Mode::Prefix; - app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4)) - .expect("test terminal"); - - terminal - .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area)) - .expect("draw prefix overlay"); - - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(rendered.contains("PREFIX")); - } - - #[test] - fn keybind_help_shows_unset_for_optional_actions() { - let app = crate::app::state::AppState::test_new(); - let groups = keybind_help_groups(&app); - - let workspace_tab = groups - .iter() - .find(|(name, _)| *name == "workspaces / tabs") - .expect("workspace tab group") - .1 - .clone(); - let panes = groups - .iter() - .find(|(name, _)| *name == "panes") - .expect("panes group") - .1 - .clone(); - - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous workspace")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next workspace")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "previous agent")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "next agent")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "focus agent 1-9")); - assert!(workspace_tab - .iter() - .any(|(key, label)| key == "unset" && label.as_ref() == "switch workspace 1-9")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+h" && label.as_ref() == "focus pane left")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+j" && label.as_ref() == "focus pane down")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+k" && label.as_ref() == "focus pane up")); - assert!(panes - .iter() - .any(|(key, label)| key == "prefix+l" && label.as_ref() == "focus pane right")); - } - - #[test] - fn keybind_help_shows_custom_command_descriptions() { - let mut app = crate::app::state::AppState::test_new(); - app.keybinds.custom_commands = vec![ - crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("alt+g"), - label: "prefix+alt+g".to_string(), - command: "lazygit".to_string(), - action: crate::config::CustomCommandAction::Pane, - description: Some("open lazygit".to_string()), - width: None, - height: None, - }, - crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("alt+h"), - label: "prefix+alt+h".to_string(), - command: "echo hello".to_string(), - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }, - ]; - - let groups = keybind_help_groups(&app); - let custom = groups - .iter() - .find(|(name, _)| *name == "custom") - .expect("custom group") - .1 - .clone(); - assert!(custom - .iter() - .any(|(key, label)| key == "prefix+alt+g" && label.as_ref() == "open lazygit")); - assert!(custom - .iter() - .any(|(key, label)| key == "prefix+alt+h" && label.as_ref() == "custom command")); - - let rendered_help = keybind_help_lines(&app) - .into_iter() - .flat_map(|(_, line)| line.spans) - .map(|span| span.content.into_owned()) - .collect::>() - .join(""); - assert!(rendered_help.contains("open lazygit")); - assert!(rendered_help.contains("custom command")); - } - - #[test] - fn keybind_help_compacts_multiple_indexed_ranges() { - let config: crate::config::Config = toml::from_str( - r#" -[keys] -switch_tab = ["prefix+1..9", "alt+1..9"] -switch_workspace = "ctrl+1..9" -"#, - ) - .expect("config parses"); - - let mut app = crate::app::state::AppState::test_new(); - app.keybinds = config.keybinds(); - - let workspace_tab = keybind_help_groups(&app) - .into_iter() - .find(|(name, _)| *name == "workspaces / tabs") - .expect("workspace tab group") - .1; - - let switch_tab_key = workspace_tab - .iter() - .find(|(_, label)| label.as_ref() == "switch tab 1-9") - .map(|(key, _)| key.as_str()) - .expect("switch tab help entry"); - let switch_workspace_key = workspace_tab - .iter() - .find(|(_, label)| label.as_ref() == "switch workspace 1-9") - .map(|(key, _)| key.as_str()) - .expect("switch workspace help entry"); - - assert_eq!(switch_tab_key, "prefix+1..9 / alt+1..9"); - assert_eq!(switch_workspace_key, "ctrl+1..9"); - } +fn rectangles_overlap(left: Rect, right: Rect) -> bool { + left.x < right.right() + && right.x < left.right() + && left.y < right.bottom() + && right.y < left.bottom() } diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs deleted file mode 100644 index ff6ced3d..00000000 --- a/src/ui/dialogs.rs +++ /dev/null @@ -1,1158 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph, Wrap}, - Frame, -}; - -use super::text::{display_width_u16, truncate_end}; -use super::widgets::{ - action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, -}; -use crate::app::{state::WorktreeOpenState, AppState, Mode}; -use crate::terminal::TerminalRuntimeRegistry; - -const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68; -const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12; - -pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "save", - }, - ActionButtonSpec { - hint: Some("^c"), - label: "clear", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1], rects[2]) -} - -/// Draws the shared `name_input` field and puts the host cursor on its caret. -/// -/// IMEs draw their composition preview at the host terminal cursor. Without an -/// explicit cursor the frame carries none, the client keeps the position last -/// reported by the focused pane, and composition lands behind the dialog. -fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) { - frame.render_widget(Clear, input_rect); - - // The text stops one column short of the field so the clamped caret always - // lands on a blank cell: a host terminal inverts the cell under its cursor, - // and an IME composes there. - let text_rect = Rect { - width: input_rect.width.saturating_sub(1), - ..input_rect - }; - frame.render_widget( - Paragraph::new(format!(" {}", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - text_rect, - ); - - if input_rect.width == 0 { - return; - } - let caret_x = input_rect - .x - .saturating_add(1) - .saturating_add(display_width_u16(&app.name_input)) - .min(input_rect.right().saturating_sub(1)); - frame.set_cursor_position((caret_x, input_rect.y)); -} - -pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - - let title = match app.mode { - Mode::RenameWorkspace if app.pending_workspace_create_cwd.is_some() => "new workspace", - Mode::RenameWorkspace => "rename workspace", - Mode::RenameTab if app.creating_new_tab => "new tab", - Mode::RenameTab => "rename tab", - Mode::RenamePane => "rename pane", - _ => return, - }; - - let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else { - return; - }; - if inner.height < 4 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(inner); - - render_modal_header(frame, rows[0], title, &app.palette); - - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); - - render_action_button( - frame, - save_rect, - Some("↵"), - "save", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - clear_rect, - Some("^c"), - "clear", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option { - centered_popup_rect( - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - ) - .map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "create and open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option { - centered_popup_rect(area, 72, 10) -} - -pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) { - let primary_label = if force_confirmation { - "delete anyway" - } else { - "remove" - }; - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: primary_label, - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option { - let height = (entry_count as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - centered_popup_rect(area, 96, height).map(|popup| { - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - }) -} - -pub(crate) fn open_existing_worktree_max_visible_rows(inner: Rect) -> usize { - usize::from(inner.height.saturating_sub(5) / 2) -} - -pub(crate) fn open_existing_worktree_visible_start( - open: &WorktreeOpenState, - max_rows: usize, -) -> usize { - let filtered = open.filtered_indices(); - let selected = open.selected_entry_index().unwrap_or(open.selected); - let selected_pos = filtered - .iter() - .position(|idx| *idx == selected) - .unwrap_or(0); - selected_pos.saturating_sub(max_rows.saturating_sub(1)) -} - -pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "open", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (rects[0], rects[1]) -} - -pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(create) = app.worktree_create.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(inner) = render_modal_shell( - frame, - area, - NEW_LINKED_WORKTREE_POPUP_WIDTH, - NEW_LINKED_WORKTREE_POPUP_HEIGHT, - &app.palette, - ) else { - return; - }; - if inner.height < 9 { - return; - } - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - render_modal_header(frame, rows[0], "new worktree", &app.palette); - - frame.render_widget( - Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - render_name_input_field(app, frame, input_rect); - - let checkout = create.checkout_path.display().to_string(); - frame.render_widget( - Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - frame.render_widget( - Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)), - rows[4], - ); - - if create.creating { - frame.render_widget( - Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &create.error { - frame.render_widget( - Paragraph::new(format!(" {error}")) - .style(Style::default().fg(app.palette.red)) - .wrap(Wrap { trim: false }), - rows[5], - ); - } - - let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner); - render_action_button( - frame, - create_rect, - Some("↵"), - "create and open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(remove) = app.worktree_remove.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let Some(popup) = remove_worktree_popup_rect(area) else { - return; - }; - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<8>(inner); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " delete worktree checkout?", - Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD), - )])), - rows[0], - ); - frame.render_widget( - Paragraph::new(" This removes the checkout folder:") - .style(Style::default().fg(app.palette.overlay0)), - rows[1], - ); - frame.render_widget( - Paragraph::new(format!(" {}", remove.path.display())) - .style(Style::default().fg(app.palette.text)), - rows[2], - ); - frame.render_widget( - Paragraph::new(" The branch is not deleted. The Herdr workspace will close.") - .style(Style::default().fg(app.palette.overlay0)), - rows[3], - ); - if remove.force_confirmation { - frame.render_widget( - Paragraph::new(" Dirty or untracked files will be permanently deleted.") - .style(Style::default().fg(app.palette.red)), - rows[4], - ); - } - if remove.removing { - frame.render_widget( - Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)), - rows[5], - ); - } else if let Some(error) = &remove.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - rows[5], - ); - } - - let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation); - let remove_label = if remove.force_confirmation { - "delete anyway" - } else { - "remove" - }; - render_action_button( - frame, - remove_rect, - Some("↵"), - remove_label, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(open) = app.worktree_open.as_ref() else { - return; - }; - - super::dim_background(frame, area); - let height = (open.entries.len() as u16) - .saturating_mul(2) - .saturating_add(7) - .clamp(12, 26); - let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else { - return; - }; - if inner.height < 8 { - return; - } - - render_modal_header( - frame, - Rect::new(inner.x, inner.y, inner.width, 1), - "open worktree", - &app.palette, - ); - render_open_worktree_search( - app, - frame, - Rect::new(inner.x, inner.y + 1, inner.width, 1), - open, - ); - frame.render_widget( - Paragraph::new("─".repeat(inner.width as usize)) - .style(Style::default().fg(app.palette.surface1)), - Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1), - ); - - let filtered = open.filtered_indices(); - let max_rows = open_existing_worktree_max_visible_rows(inner); - let start = open_existing_worktree_visible_start(open, max_rows); - for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() { - let Some(entry) = open.entries.get(*entry_idx) else { - continue; - }; - let selected = Some(*entry_idx) == open.selected_entry_index(); - let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2)); - let marker = if selected { "›" } else { " " }; - let row_style = if selected { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.subtext0) - }; - let path_style = if selected { - Style::default() - .fg(app.palette.subtext0) - .bg(app.palette.surface0) - } else { - Style::default().fg(app.palette.overlay0) - }; - let status = entry.status_label(); - let title_width = inner - .width - .saturating_sub(display_width_u16(status)) - .saturating_sub(4) as usize; - let mut title = format!( - "{marker} {}", - truncate_end(&entry.display_name(), title_width) - ); - if !status.is_empty() { - let pad = inner - .width - .saturating_sub(display_width_u16(&title)) - .saturating_sub(display_width_u16(status)) - .max(1); - title.push_str(&" ".repeat(pad as usize)); - title.push_str(status); - } - frame.render_widget( - Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style), - Rect::new(inner.x, y, inner.width, 1), - ); - frame.render_widget( - Paragraph::new(truncate_end( - &format!(" {}", entry.path.display()), - inner.width as usize, - )) - .style(path_style), - Rect::new(inner.x, y.saturating_add(1), inner.width, 1), - ); - } - - if filtered.is_empty() { - frame.render_widget( - Paragraph::new(" no matching worktrees") - .style(Style::default().fg(app.palette.overlay0)), - Rect::new(inner.x, inner.y.saturating_add(3), inner.width, 1), - ); - } - - if let Some(error) = &open.error { - frame.render_widget( - Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)), - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - 1, - ), - ); - } - - let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner); - render_action_button( - frame, - open_rect, - Some("↵"), - "open", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); -} - -fn render_open_worktree_search( - app: &AppState, - frame: &mut Frame, - area: Rect, - open: &WorktreeOpenState, -) { - let focus_style = if open.search_focused { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(app.palette.overlay0) - }; - let filtered_count = open.filtered_indices().len(); - let count = if open.query.trim().is_empty() { - format!("{} checkouts", open.entries.len()) - } else { - format!("{filtered_count}/{} checkouts", open.entries.len()) - }; - let mut spans = vec![Span::styled(" / ", focus_style)]; - if open.query.trim().is_empty() { - spans.push(Span::styled( - "filter worktrees", - Style::default().fg(app.palette.overlay0), - )); - } else { - spans.push(Span::styled( - open.query.clone(), - Style::default().fg(app.palette.text), - )); - } - spans.push(Span::styled( - format!( - "{count:>width$}", - width = area.width.saturating_sub(18) as usize - ), - Style::default().fg(app.palette.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn confirm_close_overlay_text( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, -) -> (String, String) { - let ws_name = app - .workspaces - .get(app.selected) - .map(|ws| ws.display_name_from(&app.terminals, terminal_runtimes)) - .unwrap_or_else(|| "?".to_string()); - let selected_space = app - .workspaces - .get(app.selected) - .and_then(|ws| ws.worktree_space()); - let group_member_indices = selected_space - .filter(|space| !space.is_linked_worktree) - .map(|space| { - app.workspaces - .iter() - .enumerate() - .filter_map(|(idx, ws)| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - .then_some(idx) - }) - .collect::>() - }) - .unwrap_or_default(); - let closes_group = group_member_indices.len() > 1; - let pane_count = if closes_group { - group_member_indices - .iter() - .filter_map(|idx| app.workspaces.get(*idx)) - .map(|ws| ws.layout.pane_count()) - .sum() - } else { - app.workspaces - .get(app.selected) - .map(|ws| ws.layout.pane_count()) - .unwrap_or(0) - }; - - let pane_text = if pane_count == 1 { - "1 pane".to_string() - } else { - format!("{pane_count} panes") - }; - let workspace_text = if closes_group { - let count = group_member_indices.len(); - if count == 1 { - "1 workspace, ".to_string() - } else { - format!("{count} workspaces, ") - } - } else { - String::new() - }; - - let title = if closes_group { - "Close worktree group?" - } else { - "Close workspace?" - }; - let detail = format!("{ws_name} — {workspace_text}{pane_text}"); - (title.to_string(), detail) -} - -pub(super) fn render_confirm_close_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let (title, detail) = confirm_close_overlay_text(app, terminal_runtimes); - - super::dim_background(frame, area); - - let Some(popup) = confirm_close_popup_rect(area) else { - return; - }; - - let warn = Style::default() - .fg(app.palette.red) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]); - - let detail_line = Line::from(vec![ - Span::styled( - format!(" {}", detail.split(" — ").next().unwrap_or(&detail)), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - detail - .split_once(" — ") - .map(|(_, rest)| format!(" — {rest}")) - .unwrap_or_default(), - dim, - ), - ]); - - let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) - else { - return; - }; - - if inner.height >= 3 { - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<4>(inner); - - frame.render_widget(Paragraph::new(title_line), rows[0]); - frame.render_widget(Paragraph::new(detail_line), rows[1]); - - let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); - render_action_button( - frame, - confirm_rect, - Some("↵"), - "confirm", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - cancel_rect, - Some("esc"), - "cancel", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - } -} - -pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option { - centered_popup_rect(area, 64, 6) -} - -pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: "confirm", - }, - ActionButtonSpec { - hint: Some("esc"), - label: "cancel", - }, - ], - 2, - 3, - ); - (rects[0], rects[1]) -} - -#[cfg(test)] -mod tests { - use crate::{ - app::{state::WorktreeCreateState, AppState, Mode}, - workspace::Workspace, - }; - use ratatui::{ - backend::TestBackend, - buffer::Buffer, - layout::{Position, Rect}, - Terminal, - }; - - use super::{ - confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay, - }; - - #[test] - fn confirm_close_text_uses_live_workspace_cwd_label() { - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = "/projects/original".into(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = "/projects/current".into(); - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close workspace?"); - assert_eq!(detail, "current — 1 pane"); - } - - #[cfg(unix)] - #[tokio::test] - async fn confirm_close_text_prefers_live_runtime_cwd_over_stale_terminal_cwd() { - let root = std::env::temp_dir().join(format!( - "herdr-confirm-close-runtime-cwd-{}", - std::process::id() - )); - let stale_cwd = root.join("original"); - let live_cwd = root.join("current"); - std::fs::create_dir_all(&live_cwd).unwrap(); - - let mut app = AppState::test_new(); - let mut workspace = Workspace::test_new("initial"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let root_pane = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - root_pane, - 24, - 80, - live_cwd, - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - terminal_runtimes.insert(terminal_id, runtime); - - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "current — 1 pane"); - - drop(terminal_runtimes); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn confirm_close_text_uses_selected_custom_name_instead_of_active_workspace_cwd() { - let mut app = AppState::test_new(); - let active = Workspace::test_new("active"); - let selected = Workspace::test_new("selected"); - let selected_root = selected.tabs[0].root_pane; - let selected_terminal_id = selected.tabs[0].panes[&selected_root] - .attached_terminal_id - .clone(); - app.workspaces = vec![active, selected]; - app.ensure_test_terminals(); - app.terminals.get_mut(&selected_terminal_id).unwrap().cwd = "/projects/current".into(); - app.active = Some(0); - app.selected = 1; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(detail, "selected — 1 pane"); - } - - #[test] - fn confirm_close_text_reports_parent_group_scope() { - let mut app = AppState::test_new(); - let mut parent = Workspace::test_new("main"); - parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - let mut child = Workspace::test_new("issue"); - child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - app.workspaces = vec![parent, child]; - app.selected = 0; - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes); - - assert_eq!(title, "Close worktree group?"); - assert_eq!(detail, "main — 2 workspaces, 2 panes"); - } - - #[test] - fn new_worktree_error_renders_fatal_stderr_line() { - let mut app = AppState::test_new(); - app.name_input = "foo".into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "foo".into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: Some( - "Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists" - .into(), - ), - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30))) - .expect("new worktree overlay should render"); - let rendered = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - - assert!(rendered.contains("fatal: a branch named 'foo' already exists")); - } - - #[test] - fn new_worktree_hit_test_geometry_matches_modal_size() { - let area = Rect::new(0, 0, 100, 30); - let inner = super::new_linked_worktree_inner_rect(area).unwrap(); - let (create, cancel) = super::new_linked_worktree_button_rects(inner); - - assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2); - assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2); - assert_eq!(create.y, inner.y + inner.height - 1); - assert_eq!(cancel.y, inner.y + inner.height - 1); - } - - const RENAME_AREA: Rect = Rect { - x: 0, - y: 0, - width: 80, - height: 20, - }; - const WORKTREE_AREA: Rect = Rect { - x: 0, - y: 0, - width: 100, - height: 30, - }; - - /// Reproduces the input row that `render_rename_overlay` lays out: the - /// centred popup, the border inset, then the third row of the vertical - /// split. - fn rename_input_rect(area: Rect) -> Rect { - let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits"); - let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2); - Rect::new(inner.x, inner.y + 2, inner.width, 1) - } - - fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) { - let mut app = AppState::test_new(); - app.mode = mode; - app.name_input = name.into(); - - let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA)) - .expect("rename overlay should render"); - let caret = terminal.get_cursor_position().expect("cursor position"); - (caret, terminal.backend().buffer().clone()) - } - - fn rename_overlay_caret(name: &str) -> Position { - rename_overlay_caret_in(Mode::RenameWorkspace, name).0 - } - - fn worktree_overlay_caret(branch: &str) -> Position { - let mut app = AppState::test_new(); - app.name_input = branch.into(); - app.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: None, - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: "/repo/.worktrees/herdr/foo".into(), - error: None, - creating: false, - }); - - let mut terminal = - Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height)) - .expect("test terminal"); - terminal - .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA)) - .expect("new worktree overlay should render"); - terminal.get_cursor_position().expect("cursor position") - } - - #[test] - fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() { - let input = rename_input_rect(RENAME_AREA); - - // Without an explicit cursor the frame carries none, the client parks the - // host cursor where the focused pane last reported it, and the IME - // composes there instead of in the dialog. - assert_eq!( - rename_overlay_caret(""), - Position::new(input.x + 1, input.y), - "empty input should put the caret past the one-column left padding" - ); - assert_eq!( - rename_overlay_caret("abcd"), - Position::new(input.x + 5, input.y) - ); - - // The cell under the caret has to be blank: a host terminal draws its - // cursor by inverting that cell, so a glyph there would swallow it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab"); - assert_eq!(caret, Position::new(input.x + 3, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b"); - } - - #[test] - fn rename_overlay_anchors_the_cursor_in_every_rename_mode() { - let input = rename_input_rect(RENAME_AREA); - let expected = Position::new(input.x + 3, input.y); - - for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] { - assert_eq!( - rename_overlay_caret_in(mode, "ab").0, - expected, - "{mode:?} should anchor the caret like the other rename modes" - ); - } - } - - #[test] - fn rename_overlay_caret_counts_wide_characters_as_two_columns() { - let input = rename_input_rect(RENAME_AREA); - - // "あい" is two columns per character, so the caret sits two cells further - // right than the two-column "ab". - assert_eq!( - rename_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - assert_eq!( - rename_overlay_caret("aあ"), - Position::new(input.x + 4, input.y) - ); - } - - #[test] - fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() { - let input = rename_input_rect(RENAME_AREA); - let last_column = input.right() - 1; - - // The field is 54 columns wide. 51 characters is the last name whose - // caret still lands strictly inside it; from 52 on the unclamped column - // would leave the field and gets pinned to the final cell. - assert_eq!( - rename_overlay_caret(&"a".repeat(51)), - Position::new(input.x + 52, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(53)), - Position::new(last_column, input.y) - ); - assert_eq!( - rename_overlay_caret(&"a".repeat(200)), - Position::new(last_column, input.y) - ); - - // The clamped cell has to stay blank as well, or the host cursor would - // sit on a glyph and the IME would compose over it. - let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200)); - assert_eq!(caret, Position::new(last_column, input.y)); - assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); - assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a"); - } - - #[test] - fn rename_overlay_caret_reaches_the_frame_the_server_sends() { - let input = rename_input_rect(RENAME_AREA); - let mut app = AppState::test_new(); - app.mode = Mode::RenameWorkspace; - app.name_input = "ab".into(); - - // The widget tests above stop at the ratatui frame. This one goes through - // the server's cursor resolution, which is where the bug lived: the frame - // used to leave here with `cursor: None`. - let (_, cursor) = - crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false); - let cursor = cursor.expect("the modal caret should survive cursor resolution"); - - assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y)); - assert!(cursor.visible); - } - - #[test] - fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() { - let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits"); - let input = Rect::new(popup.x, popup.y + 2, popup.width, 1); - - assert_eq!( - worktree_overlay_caret(""), - Position::new(input.x + 1, input.y) - ); - assert_eq!( - worktree_overlay_caret("ab"), - Position::new(input.x + 3, input.y) - ); - assert_eq!( - worktree_overlay_caret("あい"), - Position::new(input.x + 5, input.y) - ); - } -} diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs deleted file mode 100644 index 1f01c60c..00000000 --- a/src/ui/keybind_help.rs +++ /dev/null @@ -1,426 +0,0 @@ -use std::borrow::Cow; - -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Paragraph, Wrap}, - Frame, -}; - -use super::release_notes::release_notes_close_button_rect; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, - render_modal_shell, -}; -use crate::app::AppState; - -pub(super) type HelpEntry = (String, Cow<'static, str>); -pub(super) type HelpGroup = (&'static str, Vec); - -fn help_entry(key: impl Into, label: &'static str) -> HelpEntry { - (key.into(), Cow::Borrowed(label)) -} - -fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings.label().unwrap_or_else(|| "unset".to_string()) -} - -fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String { - if bindings.is_empty() { - return "unset".to_string(); - } - - let mut parts = Vec::new(); - let mut index = 0; - while index < bindings.len() { - if let Some(prefix) = indexed_range_prefix(&bindings[index..]) { - parts.push(format!("{prefix}1..9")); - index += 9; - } else { - parts.push(bindings[index].label.clone()); - index += 1; - } - } - - parts.join(" / ") -} - -fn indexed_range_prefix(bindings: &[crate::config::IndexedKeybind]) -> Option<&str> { - let run = bindings.get(..9)?; - let prefix = run[0].label.strip_suffix('1')?; - for (offset, binding) in run.iter().enumerate() { - let digit = char::from(b'1' + offset as u8); - if binding.label.strip_suffix(digit) != Some(prefix) { - return None; - } - } - Some(prefix) -} - -pub(super) fn keybind_help_groups(app: &AppState) -> Vec { - let kb = &app.keybinds; - let mut groups = Vec::new(); - - groups.push(( - "global", - vec![ - help_entry( - crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), - "prefix mode", - ), - help_entry(keybind_label(&kb.help), "keybinds"), - help_entry(keybind_label(&kb.settings), "settings"), - help_entry(keybind_label(&kb.detach), "detach"), - help_entry(keybind_label(&kb.reload_config), "reload config"), - help_entry( - keybind_label(&kb.open_notification_target), - "open notification target", - ), - ], - )); - - groups.push(( - "navigation", - vec![ - help_entry("esc", "back"), - help_entry( - format!( - "{} / {}", - keybind_label(&kb.navigate.workspace_up), - keybind_label(&kb.navigate.workspace_down) - ), - "workspace list", - ), - help_entry( - format!( - "{} / {} / {} / {} / left / right", - keybind_label(&kb.navigate.pane_left), - keybind_label(&kb.navigate.pane_down), - keybind_label(&kb.navigate.pane_up), - keybind_label(&kb.navigate.pane_right) - ), - "move focus", - ), - help_entry("tab / shift+tab", "cycle pane"), - help_entry("enter", "open workspace"), - help_entry("1..9", "switch workspace"), - ], - )); - - let workspace_tab = vec![ - help_entry(keybind_label(&kb.workspace_picker), "workspace navigation"), - help_entry(keybind_label(&kb.goto), "session navigator"), - help_entry(keybind_label(&kb.new_workspace), "new workspace"), - help_entry(keybind_label(&kb.new_worktree), "new worktree"), - help_entry(keybind_label(&kb.open_worktree), "open worktree"), - help_entry( - keybind_label(&kb.remove_worktree), - "delete worktree checkout", - ), - help_entry(keybind_label(&kb.rename_workspace), "rename workspace"), - help_entry(keybind_label(&kb.close_workspace), "close workspace"), - help_entry(keybind_label(&kb.previous_workspace), "previous workspace"), - help_entry(keybind_label(&kb.next_workspace), "next workspace"), - help_entry(indexed_label(&kb.switch_workspace), "switch workspace 1-9"), - help_entry(keybind_label(&kb.previous_agent), "previous agent"), - help_entry(keybind_label(&kb.next_agent), "next agent"), - help_entry(indexed_label(&kb.focus_agent), "focus agent 1-9"), - help_entry(keybind_label(&kb.new_tab), "new tab"), - help_entry(keybind_label(&kb.rename_tab), "rename tab"), - help_entry(keybind_label(&kb.previous_tab), "previous tab"), - help_entry(keybind_label(&kb.next_tab), "next tab"), - help_entry(keybind_label(&kb.move_tab_previous), "move tab left"), - help_entry(keybind_label(&kb.move_tab_next), "move tab right"), - help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"), - help_entry(keybind_label(&kb.close_tab), "close tab"), - ]; - groups.push(("workspaces / tabs", workspace_tab)); - - let panes = vec![ - help_entry(keybind_label(&kb.split_vertical), "split vertical"), - help_entry(keybind_label(&kb.split_horizontal), "split horizontal"), - help_entry(keybind_label(&kb.close_pane), "close pane"), - help_entry(keybind_label(&kb.rename_pane), "rename pane"), - help_entry(keybind_label(&kb.edit_scrollback), "edit scrollback"), - help_entry(keybind_label(&kb.copy_mode), "copy mode"), - help_entry(keybind_label(&kb.zoom), "zoom pane"), - help_entry(keybind_label(&kb.resize_mode), "resize mode"), - help_entry(keybind_label(&kb.resize_pane_left), "resize pane left"), - help_entry(keybind_label(&kb.resize_pane_down), "resize pane down"), - help_entry(keybind_label(&kb.resize_pane_up), "resize pane up"), - help_entry(keybind_label(&kb.resize_pane_right), "resize pane right"), - help_entry(keybind_label(&kb.toggle_sidebar), "toggle sidebar"), - help_entry(keybind_label(&kb.focus_pane_left), "focus pane left"), - help_entry(keybind_label(&kb.focus_pane_down), "focus pane down"), - help_entry(keybind_label(&kb.focus_pane_up), "focus pane up"), - help_entry(keybind_label(&kb.focus_pane_right), "focus pane right"), - help_entry(keybind_label(&kb.cycle_pane_next), "cycle pane next"), - help_entry( - keybind_label(&kb.cycle_pane_previous), - "cycle pane previous", - ), - help_entry(keybind_label(&kb.last_pane), "last pane"), - ]; - groups.push(("panes", panes)); - - if !kb.custom_commands.is_empty() { - groups.push(( - "custom", - kb.custom_commands - .iter() - .map(|binding| { - ( - binding.label.clone(), - binding - .description - .clone() - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed("custom command")), - ) - }) - .collect(), - )); - } - - groups -} - -fn filter_keybind_help_groups(groups: Vec, query: &str) -> Vec { - if query.is_empty() { - return groups; - } - - let query = query.to_lowercase(); - groups - .into_iter() - .filter_map(|(group, entries)| { - let entries = entries - .into_iter() - .filter(|(key, label)| { - key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) - }) - .collect::>(); - (!entries.is_empty()).then_some((group, entries)) - }) - .collect() -} - -pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> { - let heading_style = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let key_style = Style::default() - .fg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - let label_style = Style::default().fg(app.palette.text); - - let groups = filter_keybind_help_groups(keybind_help_groups(app), &app.keybind_help.query); - let key_width = groups - .iter() - .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count())) - .max() - .unwrap_or(8); - - let mut lines = Vec::new(); - - if groups.is_empty() { - let message = " no matching keybinds"; - return vec![( - message.chars().count(), - Line::from(Span::styled( - message, - Style::default().fg(app.palette.overlay1), - )), - )]; - } - - for (group, entries) in groups { - lines.push(( - group.len() + 1, - Line::from(vec![Span::styled(format!(" {group}"), heading_style)]), - )); - for (key, label) in entries { - let padded_key = format!(" {:(stack.header); - - render_modal_header(frame, header_rows[0], "keybinds", &app.palette); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - if app.keybind_help.search_focused { - "back" - } else { - "close" - }, - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - let search_line = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled( - " / ", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - app.keybind_help.query.as_str(), - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(Span::styled( - " press / to filter by command or shortcut", - Style::default().fg(app.palette.overlay0), - )) - }; - frame.render_widget(Paragraph::new(search_line), header_rows[1]); - - let body_area = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .keybind_help_max_scroll() - .saturating_sub(app.keybind_help.scroll) as usize, - max_offset_from_bottom: app.keybind_help_max_scroll() as usize, - viewport_rows: body_area.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_area, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_area.x, - body_area.y, - body_area.width.saturating_sub(1), - body_area.height, - ) - }) - .unwrap_or(body_area); - - let body = Paragraph::new( - keybind_help_lines(app) - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((app.keybind_help.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - let footer = if app.keybind_help.search_focused { - Line::from(vec![ - Span::styled(" filter ", Style::default().fg(app.palette.overlay0)), - Span::styled("type/backspace", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("clear ", Style::default().fg(app.palette.overlay0)), - Span::styled("ctrl+u", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("back ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc", Style::default().fg(app.palette.text)), - ]) - } else { - Line::from(vec![ - Span::styled(" search ", Style::default().fg(app.palette.overlay0)), - Span::styled("/", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("j/k/↑↓/pgup/pgdn", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close ", Style::default().fg(app.palette.overlay0)), - Span::styled("esc/enter", Style::default().fg(app.palette.text)), - ]) - }; - frame.render_widget(Paragraph::new(footer), stack.footer.unwrap_or_default()); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn groups() -> Vec { - vec![ - ( - "workspaces / tabs", - vec![ - help_entry("w", "workspace navigation"), - help_entry("c", "new tab"), - ], - ), - ( - "panes", - vec![ - help_entry("v", "split vertical"), - help_entry("x", "close pane"), - ], - ), - ] - } - - #[test] - fn keybind_help_filter_matches_labels_case_insensitively() { - let filtered = filter_keybind_help_groups(groups(), "WoRk"); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].0, "workspaces / tabs"); - assert_eq!(filtered[0].1.len(), 1); - assert_eq!(filtered[0].1[0].1, "workspace navigation"); - } - - #[test] - fn keybind_help_filter_matches_shortcuts_without_matching_group_headings() { - let filtered = filter_keybind_help_groups(groups(), "x"); - - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].0, "panes"); - assert_eq!(filtered[0].1.len(), 1); - assert_eq!(filtered[0].1[0].1, "close pane"); - - assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); - } -} diff --git a/src/ui/menus.rs b/src/ui/menus.rs deleted file mode 100644 index 763c0ea8..00000000 --- a/src/ui/menus.rs +++ /dev/null @@ -1,315 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; - -use super::widgets::{panel_contrast_fg, render_panel_shell}; -use crate::app::AppState; - -fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings - .prefix_rhs_label() - .unwrap_or_else(|| "unset".to_string()) -} - -fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { - bindings.label().unwrap_or_else(|| "unset".to_string()) -} - -fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { - frame.render_widget(Clear, area); - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)].set_style(Style::default().bg(bg)); - } - frame.render_widget(Paragraph::new(line), area); -} - -pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker); - let help = prefix_rhs_label(&app.keybinds.help); - let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods)); - - let line = Line::from(vec![ - Span::styled(" PREFIX ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" cancel ", dim), - Span::styled(prefix, key), - Span::styled(" send prefix ", dim), - Span::styled(workspace_picker, key), - Span::styled(" workspace nav ", dim), - Span::styled(help, key), - Span::styled(" keybinds", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() { - let marker = match prompt.direction { - crate::app::state::CopyModeSearchDirection::Forward => "/", - crate::app::state::CopyModeSearchDirection::Backward => "?", - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled(marker, key), - Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)), - Span::styled("█", key), - Span::styled(" enter search esc cancel", dim), - ]) - } else { - let select = if copy_mode.selection.is_some() { - "selecting" - } else { - "select" - }; - let match_status = copy_mode - .search - .current - .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len())) - .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string())) - .unwrap_or_default(); - let (exit_keys, exit_label) = - if copy_mode.search.query.is_empty() && copy_mode.selection.is_none() { - ("q/esc", " exit") - } else { - ("esc", " clear q exit") - }; - Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled("h/j/k/l w/b/e { }", key), - Span::styled(" move ", dim), - Span::styled("/ ?", key), - Span::styled(" search ", dim), - Span::styled("n/N", key), - Span::styled(format!(" repeat{match_status} "), dim), - Span::styled("v/space", key), - Span::styled(format!(" {select} "), dim), - Span::styled("y/enter", key), - Span::styled(" copy ", dim), - Span::styled(exit_keys, key), - Span::styled(exit_label, dim), - ]) - }; - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - - let kb = &app.keybinds; - let new_tab = prefix_rhs_label(&kb.new_tab); - let split_vertical = prefix_rhs_label(&kb.split_vertical); - let split_horizontal = prefix_rhs_label(&kb.split_horizontal); - let close_pane = prefix_rhs_label(&kb.close_pane); - let zoom = prefix_rhs_label(&kb.zoom); - let resize = prefix_rhs_label(&kb.resize_mode); - let help = prefix_rhs_label(&kb.help); - let settings = prefix_rhs_label(&kb.settings); - let goto = prefix_rhs_label(&kb.goto); - let detach = prefix_rhs_label(&kb.detach); - let workspace_nav = format!( - "{} / {}", - keybind_label(&kb.navigate.workspace_up), - keybind_label(&kb.navigate.workspace_down) - ); - let line = Line::from(vec![ - Span::styled(" NAVIGATE ", mode_style), - Span::raw(" "), - Span::styled("esc", key), - Span::styled(" back ", dim), - Span::styled(workspace_nav, key), - Span::styled(" ws ", dim), - Span::styled("⇥", key), - Span::styled(" pane ", dim), - Span::styled(goto, key), - Span::styled(" navigator ", dim), - Span::styled(new_tab, key), - Span::styled(" new tab ", dim), - Span::styled(split_vertical, key), - Span::styled(" split│ ", dim), - Span::styled(split_horizontal, key), - Span::styled(" split─ ", dim), - Span::styled(close_pane, key), - Span::styled(" close ", dim), - Span::styled(zoom, key), - Span::styled(" zoom ", dim), - Span::styled(resize, key), - Span::styled(" resize ", dim), - Span::styled(help, key), - Span::styled(" keybinds ", dim), - Span::styled(settings, key), - Span::styled(" settings ", dim), - Span::styled(detach, key), - Span::styled(" detach", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); - - if app.update_available.is_some() { - let status = Line::from(vec![Span::styled( - " update ready", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - )]); - let width = 13u16.min(overlay_area.width); - let status_area = Rect::new( - overlay_area.x + overlay_area.width.saturating_sub(width), - overlay_area.y, - width, - overlay_area.height, - ); - frame.render_widget(Clear, status_area); - frame.render_widget( - Paragraph::new(status).alignment(Alignment::Right), - status_area, - ); - } -} - -pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) { - let rect = app.global_menu_rect(); - let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let items = app.global_menu_labels(); - for (idx, item) in items.iter().enumerate() { - let y = inner.y + idx as u16; - if y >= inner.y + inner.height { - break; - } - let selected = idx == app.global_menu.highlighted; - let rect = Rect::new(inner.x, y, inner.width, 1); - - let selected_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let item_style = if selected { - selected_style - } else { - Style::default().fg(app.palette.text) - }; - let badge_style = if selected { - selected_style - } else { - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD) - }; - - let line = if app.global_menu_item_has_badge(item) { - Line::from(vec![ - Span::styled(" ●", badge_style), - Span::styled(format!(" {item} "), item_style), - ]) - } else { - Line::from(Span::styled(format!(" {item} "), item_style)) - }; - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); - } -} - -pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); - let dim = Style::default().fg(app.palette.overlay0); - - let mode_style = Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.mauve) - .add_modifier(Modifier::BOLD); - - let line = Line::from(vec![ - Span::styled(" RESIZE ", mode_style), - Span::raw(" "), - Span::styled("h/l", key), - Span::styled(" width ", dim), - Span::styled("j/k", key), - Span::styled(" height ", dim), - Span::styled("esc", key), - Span::styled(" done", dim), - ]); - - let overlay_y = area.y + area.height.saturating_sub(1); - let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); - render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); -} - -pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) { - let Some(menu) = &app.context_menu else { - return; - }; - - let p = &app.palette; - let Some(menu_rect) = app.context_menu_rect() else { - return; - }; - let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else { - return; - }; - - let items: Vec = menu - .items() - .iter() - .map(|item| ListItem::new(Line::from(*item))) - .collect(); - let list = List::new(items) - .style(Style::default().fg(p.text)) - .highlight_style( - Style::default() - .bg(p.accent) - .fg(panel_contrast_fg(p)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" "); - let mut state = ListState::default().with_selected(Some(menu.list.highlighted)); - frame.render_stateful_widget(list, inner, &mut state); -} diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs deleted file mode 100644 index bdec6551..00000000 --- a/src/ui/mobile.rs +++ /dev/null @@ -1,1626 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::sidebar::{ - agent_panel_entries, agent_panel_entries_from, grouped_child_display_label, - next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry, - WorkspaceListEntry, -}; -use super::status::{state_icon, state_icon_symbol}; -use super::text::{display_width_u16, truncate_end}; -use crate::app::state::{Palette, ToastKind, ToastNotification}; -use crate::app::AppState; -use crate::config::StatusIndicatorStyle; -use crate::detect::AgentState; -use crate::layout::PaneId; -use crate::terminal::TerminalRuntimeRegistry; - -const SWITCH_BUTTON_WIDTH: u16 = 10; - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileHeaderHitAreas { - pub menu: Rect, -} - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct MobileSwitcherAreas { - pub close: Rect, - pub viewport: Rect, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MobileSwitcherTarget { - NewWorkspace, - Workspace(usize), - NewTab, - Tab(usize), - Agent { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, - Menu(usize), -} - -pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool { - area.width > 0 && area.width <= threshold -} - -pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { - if area.width == 0 || area.height == 0 { - return MobileHeaderHitAreas::default(); - } - - let width = SWITCH_BUTTON_WIDTH.min(area.width); - let switch = Rect::new( - area.x + area.width.saturating_sub(width), - area.y, - width, - area.height, - ); - - MobileHeaderHitAreas { menu: switch } -} - -pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { - let screen = mobile_screen_rect(app); - if screen.width == 0 || screen.height <= 2 { - return MobileSwitcherAreas::default(); - } - - let header_h = screen.height.min(2); - let close_w = 10u16.min(screen.width); - let close = Rect::new( - screen.x + screen.width.saturating_sub(close_w), - screen.y, - close_w, - header_h, - ); - let viewport = Rect::new( - screen.x, - screen.y + header_h + 1, - screen.width, - screen.height.saturating_sub(header_h + 1), - ); - - MobileSwitcherAreas { close, viewport } -} - -pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { - mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) -} - -/// Doc-row height of the agents section. An active query keeps its title and an -/// empty-state row visible even when no agents match. -fn mobile_agents_block_height(app: &AppState) -> usize { - let count = agent_panel_entries(app).len(); - if count == 0 { - usize::from(app.agent_view_override.is_some()) * 2 - } else { - 1 + count * 2 - } -} - -pub(crate) fn mobile_switcher_workspace_doc_range( - app: &AppState, - idx: usize, -) -> std::ops::Range { - // Spaces render in grouped order, so a workspace's row position is its index - // in the entry list, not its raw array index. - let pos = workspace_list_entries_expanded(app) - .iter() - .position(|WorkspaceListEntry::Workspace { ws_idx, .. }| *ws_idx == idx) - .unwrap_or(idx); - // spaces sit after the agents block, then a title + "new workspace" row. - let start = mobile_agents_block_height(app) + 2 + pos * 2; - start..start + 2 -} - -pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { - mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) -} - -pub(crate) fn mobile_switcher_target_at( - app: &AppState, - col: u16, - row: u16, -) -> Option { - let areas = mobile_switcher_areas(app); - let content = inset_for_left_scrollbar(areas.viewport); - if !rect_contains(content, col, row) { - return None; - } - - let scroll = app - .mobile_switcher_scroll - .min(mobile_switcher_max_scroll_for_height( - app, - areas.viewport.height, - )); - let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize); - let mut cursor = 0usize; - - // Agents lead the switcher: the primary job is switching between running - // agents. Spaces/tabs/create actions follow for navigation and management. - let agents = agent_panel_entries(app); - if !agents.is_empty() || app.agent_view_override.is_some() { - cursor += 1; // agents title - if agents.is_empty() { - cursor += 1; // active-query empty state - } else { - let agents_end = cursor + agents.len() * 2; - if doc_row >= cursor && doc_row < agents_end { - let idx = (doc_row - cursor) / 2; - return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { - ws_idx: entry.ws_idx, - tab_idx: entry.tab_idx, - pane_id: entry.pane_id, - }); - } - cursor = agents_end; - } - } - - cursor += 1; // spaces title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewWorkspace); - } - cursor += 1; - // Spaces render in grouped (worktree-tree) order, which differs from raw - // array order, so map the clicked row to the entry's real workspace index. - let space_entries = workspace_list_entries_expanded(app); - let spaces_end = cursor + space_entries.len() * 2; - if doc_row >= cursor && doc_row < spaces_end { - let entry_idx = (doc_row - cursor) / 2; - return space_entries.get(entry_idx).map( - |WorkspaceListEntry::Workspace { ws_idx, .. }| MobileSwitcherTarget::Workspace(*ws_idx), - ); - } - cursor = spaces_end; - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - cursor += 1; // tabs title - if doc_row == cursor { - return Some(MobileSwitcherTarget::NewTab); - } - cursor += 1; - let tabs_end = cursor + ws.tabs.len(); - if doc_row >= cursor && doc_row < tabs_end { - return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); - } - cursor = tabs_end; - } - - cursor += 1; // menu title - let menu_idx = doc_row.checked_sub(cursor)?; - (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) -} - -pub(crate) fn render_mobile_header( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let switch = app.view.mobile_menu_hit_area; - let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); - let status = Rect::new(area.x, area.y, status_w, area.height); - - render_header_status(app, terminal_runtimes, frame, status); - render_switch_button(app, frame, switch); -} - -pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect { - if area.width == 0 || area.height == 0 { - return Rect::default(); - } - - let y = area.y - + area - .height - .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); - Rect::new(area.x, y, area.width, 1) -} - -pub(crate) fn render_mobile_toast_banner( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - p: &Palette, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let banner = mobile_toast_banner_rect(area, offset_for_warning); - let bg = p.surface0; - - frame.render_widget(Clear, banner); - fill_rect(frame, banner, Style::default().bg(bg)); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled("●", Style::default().fg(dot_color).bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - mobile_toast_title(toast), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), - Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), - ])), - banner, - ); -} - -pub(crate) fn render_mobile_panel( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - - let p = &app.palette; - frame.render_widget(Clear, area); - fill_rect(frame, area, Style::default().bg(p.panel_bg)); - - let areas = mobile_switcher_areas(app); - frame.render_widget( - Paragraph::new(" switch").style( - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), - ); - render_close_button(app, frame, areas.close); - - if area.height > areas.close.height { - draw_horizontal_rule( - frame, - Rect::new(area.x, area.y + areas.close.height, area.width, 1), - p, - ); - } - - render_mobile_switcher_content(app, terminal_runtimes, frame, areas.viewport); -} - -fn render_header_status( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { - frame.render_widget(Paragraph::new(" no workspace"), area); - return; - }; - - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - let tab_label = mobile_tab_status(ws); - let row1 = Rect::new(area.x, area.y, area.width, 1); - let tab_w = display_width_u16(&tab_label) - .saturating_add(1) - .min(area.width); - let name_w = area.width.saturating_sub(tab_w); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled(dot, dot_style.bg(p.panel_bg)), - Span::raw(" "), - Span::styled( - truncate_end( - &ws.display_name_from(&app.terminals, terminal_runtimes), - name_w.saturating_sub(4) as usize, - ), - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - ])), - Rect::new(row1.x, row1.y, name_w, 1), - ); - frame.render_widget( - Paragraph::new(tab_label) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) - .alignment(Alignment::Right), - Rect::new(row1.x + name_w, row1.y, tab_w, 1), - ); - - if area.height > 1 { - frame.render_widget( - Paragraph::new(agent_summary_line(app, p, area.width)), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - } -} - -fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { - let tab_label = ws - .tab_display_name(ws.active_tab) - .unwrap_or_else(|| (ws.active_tab + 1).to_string()); - if ws.tabs.len() <= 1 { - format!("tab {tab_label}") - } else { - format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len()) - } -} - -fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - let label_y = if area.height > 1 { area.y + 1 } else { area.y }; - frame.render_widget( - Paragraph::new("switch") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), - ); - - // Attention badge: a blocked agent anywhere makes the button itself read as - // "tap me" without the user reading the summary row. - if global_agent_counts(app).blocked > 0 { - let bx = area.x + area.width.saturating_sub(1); - let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p); - frame.buffer_mut()[(bx, area.y)] - .set_symbol(symbol) - .set_style(style.bg(p.surface0)); - } -} - -fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let p = &app.palette; - fill_rect(frame, area, Style::default().bg(p.surface0)); - for y in area.y..area.y + area.height { - frame.buffer_mut()[(area.x, y)] - .set_symbol("│") - .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); - } - frame.render_widget( - Paragraph::new("close") - .style( - Style::default() - .fg(p.overlay1) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), - ); - if area.height > 1 { - frame.render_widget( - Paragraph::new("×") - .style( - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center), - Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), - ); - } -} - -fn mobile_switcher_content_height(app: &AppState) -> usize { - // Derive spaces height from the same entry list the render/hit-test use so - // the three never disagree. - let spaces_h = 2 + workspace_list_entries_expanded(app).len() * 2; - let tabs_h = app - .active - .and_then(|idx| app.workspaces.get(idx)) - .map(|ws| 2 + ws.tabs.len()) - .unwrap_or(0); - let agents_h = mobile_agents_block_height(app); - let menu_h = 1 + app.global_menu_labels().len(); - spaces_h + tabs_h + agents_h + menu_h -} - -fn render_mobile_switcher_content( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - viewport: Rect, -) { - if viewport.width == 0 || viewport.height == 0 { - return; - } - - let p = &app.palette; - let total_height = mobile_switcher_content_height(app); - render_left_scrollbar( - frame, - viewport, - total_height, - viewport.height as usize, - app.mobile_switcher_scroll, - p, - ); - let content = inset_for_left_scrollbar(viewport); - if content == Rect::default() { - return; - } - - let mut doc_y = 0usize; - - let entries = agent_panel_entries_from(app, terminal_runtimes); - if !entries.is_empty() || app.agent_view_override.is_some() { - let focused_agent = app.active.and_then(|ws_idx| { - let ws = app.workspaces.get(ws_idx)?; - ws.focused_pane_id() - .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) - }); - let title = app - .agent_view_override - .as_ref() - .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered"))) - .unwrap_or_else(|| "agents".to_string()); - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - &title, - p, - ); - doc_y += 1; - if entries.is_empty() { - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - ratatui::style::Color::Reset, - Line::from(Span::styled( - " no matching agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), - )), - ); - doc_y += 1; - } - for entry in &entries { - let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { - entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id - }); - let bg = mobile_item_bg(false, active, p); - let (icon, icon_style) = state_icon(entry.state, entry.seen, app.status_indicators, p); - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled(icon, icon_style.bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end( - &entry.primary_label, - content.width.saturating_sub(5) as usize, - ), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - let detail = mobile_agent_detail(entry); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "spaces", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new workspace", - p, - ); - doc_y += 1; - let space_entries = workspace_list_entries_expanded(app); - for (entry_idx, WorkspaceListEntry::Workspace { ws_idx, indented }) in - space_entries.iter().enumerate() - { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let active = Some(*ws_idx) == app.active; - let selected = *ws_idx == app.selected; - let bg = mobile_item_bg(selected, active, p); - let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); - - let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))]; - // Worktrees of the same space render as branches off their parent, so a - // child gets an L/T connector on its name row and a matching vertical - // continuation on its detail row. - let detail_prefix = if *indented { - let last_child = !next_entry_is_indented_workspace(&space_entries, entry_idx); - title_spans.push(Span::styled( - if last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0).bg(bg), - )); - if last_child { - " " - } else { - " │ " - } - } else { - " " - }; - - title_spans.push(Span::styled(dot, dot_style.bg(bg))); - title_spans.push(Span::styled(" ", Style::default().bg(bg))); - let raw_label = ws.display_name_from(&app.terminals, terminal_runtimes); - let name = if *indented { - grouped_child_display_label( - &raw_label, - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - raw_label - }; - let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize; - title_spans.push(Span::styled( - truncate_end(&name, name_budget), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - )); - - let detail = format!( - "{detail_prefix}{} · {}", - ws.branch().unwrap_or_else(|| "shell".into()), - mobile_tab_status(ws) - ); - render_two_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - Line::from(title_spans), - truncate_end(&detail, content.width as usize), - p.overlay0, - ); - doc_y += 2; - } - - if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "tabs", - p, - ); - doc_y += 1; - render_action_row_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "+ new tab", - p, - ); - doc_y += 1; - for (idx, tab) in ws.tabs.iter().enumerate() { - let active = idx == ws.active_tab; - let bg = mobile_item_bg(false, active, p); - let display_name = ws - .tab_display_name(idx) - .unwrap_or_else(|| (idx + 1).to_string()); - let label = if tab.is_auto_named() { - format!("tab {display_name}") - } else { - format!("{} · {display_name}", idx + 1) - }; - let title = Line::from(vec![ - Span::styled(" ", Style::default().bg(bg)), - Span::styled( - truncate_end(&label, content.width.saturating_sub(3) as usize), - Style::default() - .fg(p.text) - .bg(bg) - .add_modifier(Modifier::BOLD), - ), - ]); - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - bg, - title, - ); - doc_y += 1; - } - } - - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - "menu", - p, - ); - doc_y += 1; - for label in app.global_menu_labels() { - if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { - frame.render_widget( - Paragraph::new(format!(" {label}")) - .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), - Rect::new(content.x, y, content.width, 1), - ); - } - doc_y += 1; - } -} - -fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { - let mut parts = Vec::new(); - if let Some(tab_label) = entry.primary_tab_label.as_deref() { - parts.push(tab_label.to_string()); - } - let status = entry - .state_labels - .get(super::sidebar::agent_panel_status_key( - entry.state, - entry.seen, - )) - .cloned() - .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string()); - parts.push(status); - if let Some(agent_label) = entry.agent_label.as_deref() { - parts.push(agent_label.to_string()); - } - format!(" {}", parts.join(" · ")) -} - -fn render_section_title_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - title: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_section_title( - frame, - Rect::new(content.x, y, content.width.saturating_sub(1), 1), - title, - p, - ); -} - -fn render_action_row_at( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - label: &str, - p: &Palette, -) { - let Some(y) = visible_y(viewport, scroll, doc_y) else { - return; - }; - render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); -} - -fn render_one_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 1, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn render_two_line_item( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - scroll: usize, - bg: ratatui::style::Color, - title: Line<'_>, - detail: String, - detail_fg: ratatui::style::Color, -) { - fill_visible_doc_rect( - frame, - viewport, - content, - doc_y, - 2, - Style::default().bg(bg), - scroll, - ); - if let Some(y) = visible_y(viewport, scroll, doc_y) { - frame.render_widget( - Paragraph::new(title), - Rect::new(content.x, y, content.width, 1), - ); - } - if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { - frame.render_widget( - Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), - Rect::new(content.x, y, content.width, 1), - ); - } -} - -fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option { - let offset = doc_y.checked_sub(scroll)?; - (offset < viewport.height as usize).then_some(viewport.y + offset as u16) -} - -fn fill_visible_doc_rect( - frame: &mut Frame, - viewport: Rect, - content: Rect, - doc_y: usize, - height: usize, - style: Style, - scroll: usize, -) { - for offset in 0..height { - if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { - fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); - } - } -} - -fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { - if selected { - p.surface0 - } else if active { - p.surface_dim - } else { - p.panel_bg - } -} - -fn inset_for_left_scrollbar(area: Rect) -> Rect { - if area.width <= 1 { - return Rect::default(); - } - Rect::new(area.x + 1, area.y, area.width - 1, area.height) -} - -fn render_left_scrollbar( - frame: &mut Frame, - area: Rect, - total_rows: usize, - visible_rows: usize, - scroll: usize, - p: &Palette, -) { - if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { - return; - } - - let track = Rect::new(area.x, area.y, 1, area.height); - let max_scroll = total_rows.saturating_sub(visible_rows); - let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) - .max(1) - .min(track.height as usize) as u16; - let travel = track.height.saturating_sub(thumb_len); - let thumb_top = track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16; - - for y in track.y..track.y + track.height { - let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; - frame.buffer_mut()[(track.x, y)] - .set_symbol(if is_thumb { "▌" } else { "│" }) - .set_style( - Style::default() - .fg(if is_thumb { p.accent } else { p.surface_dim }) - .bg(p.panel_bg), - ); - } -} - -fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - frame.render_widget( - Paragraph::new(format!(" {title} ")).style( - Style::default() - .fg(p.overlay1) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - ), - Rect::new(area.x, area.y, area.width, 1), - ); -} - -fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - frame.render_widget( - Paragraph::new(format!(" {label}")).style( - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD), - ), - area, - ); -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn mobile_screen_rect(app: &AppState) -> Rect { - let header = app.view.mobile_header_rect; - let terminal = app.view.terminal_area; - let x = header.x.min(terminal.x); - let y = header.y.min(terminal.y); - let right = (header.x + header.width).max(terminal.x + terminal.width); - let bottom = (header.y + header.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) -} - -/// Agent state counts across every workspace. The mobile header is global on -/// purpose: while you stare at one terminal, a blocked agent anywhere should -/// still surface. -#[derive(Debug, Default, Clone, Copy)] -struct GlobalAgentCounts { - blocked: usize, - done: usize, - working: usize, - idle: usize, -} - -impl GlobalAgentCounts { - fn total(&self) -> usize { - self.blocked + self.done + self.working + self.idle - } - - fn any_pending(&self) -> bool { - self.blocked > 0 || self.done > 0 || self.working > 0 - } -} - -fn global_agent_counts(app: &AppState) -> GlobalAgentCounts { - let mut counts = GlobalAgentCounts::default(); - for entry in crate::ui::all_agent_panel_entries(app) { - match (entry.state, entry.seen) { - (AgentState::Blocked, _) => counts.blocked += 1, - (AgentState::Idle, false) => counts.done += 1, - (AgentState::Working, _) => counts.working += 1, - (AgentState::Idle, true) => counts.idle += 1, - (AgentState::Unknown, _) => {} - } - } - counts -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SummaryTone { - Blocked, - Done, - Working, - Idle, - Muted, -} - -/// Ordered, non-zero breakdown for the header roll-up: attention states lead -/// (blocked → done → working → idle). Pure so it can be unit-tested. -fn agent_summary_segments( - counts: GlobalAgentCounts, - indicator_style: StatusIndicatorStyle, -) -> Vec<(String, SummaryTone)> { - if counts.total() == 0 { - return vec![("no agents".to_string(), SummaryTone::Muted)]; - } - if !counts.any_pending() { - return vec![("all idle".to_string(), SummaryTone::Muted)]; - } - let mut segments = Vec::new(); - if counts.blocked > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Blocked, - true, - Some("◉"), - counts.blocked, - "blocked", - ), - SummaryTone::Blocked, - )); - } - if counts.done > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - false, - Some("●"), - counts.done, - "done", - ), - SummaryTone::Done, - )); - } - if counts.working > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Working, - true, - None, - counts.working, - "working", - ), - SummaryTone::Working, - )); - } - if counts.idle > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - true, - None, - counts.idle, - "idle", - ), - SummaryTone::Idle, - )); - } - segments -} - -fn agent_summary_text( - indicator_style: StatusIndicatorStyle, - state: AgentState, - seen: bool, - dot_style_symbol: Option<&str>, - count: usize, - label: &str, -) -> String { - let symbol = match indicator_style { - StatusIndicatorStyle::Dots => dot_style_symbol, - StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)), - }; - match symbol { - Some(symbol) => format!("{symbol} {count} {label}"), - None => format!("{count} {label}"), - } -} - -/// Greedily keep the most-urgent segments that fit `max_width` (counting the -/// leading space and " · " separators) and report whether any were dropped. -/// Segments are ordered by urgency, so the dropped tail is always the least -/// important state. -fn fit_summary_segments( - segments: Vec<(String, SummaryTone)>, - max_width: usize, -) -> (Vec<(String, SummaryTone)>, bool) { - let mut shown = Vec::new(); - let mut used = 1usize; // leading space - for (idx, segment) in segments.iter().enumerate() { - let sep = if idx > 0 { 3 } else { 0 }; // " · " - let seg_w = segment.0.chars().count(); - if used + sep + seg_w > max_width { - break; - } - used += sep + seg_w; - shown.push(segment.clone()); - } - let truncated = shown.len() < segments.len(); - (shown, truncated) -} - -fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> { - let segments = agent_summary_segments(global_agent_counts(app), app.status_indicators); - let (shown, truncated) = fit_summary_segments(segments, max_width as usize); - - let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))]; - let mut used = 1usize; - for (idx, (text, tone)) in shown.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled( - " · ", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - used += 3; - } - // Only the leading (most urgent) segment keeps its state color; the - // rest stay dim so the urgent count is the loud thing. - let style = if idx == 0 { - let color = match tone { - SummaryTone::Blocked => p.red, - SummaryTone::Done => p.blue, - SummaryTone::Working => p.yellow, - SummaryTone::Idle | SummaryTone::Muted => p.overlay1, - }; - let style = Style::default().fg(color).bg(p.panel_bg); - if tone == SummaryTone::Muted { - style - } else { - style.add_modifier(Modifier::BOLD) - } - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - used += text.chars().count(); - spans.push(Span::styled(text, style)); - } - if truncated && used + 2 <= max_width as usize { - spans.push(Span::styled( - " …", - Style::default().fg(p.overlay0).bg(p.panel_bg), - )); - } - Line::from(spans) -} - -fn mobile_toast_title(toast: &ToastNotification) -> String { - match toast.kind { - ToastKind::NeedsAttention => toast - .title - .strip_suffix(" needs attention") - .map(|agent| format!("{agent} waiting")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::Finished => toast - .title - .strip_suffix(" finished") - .map(|agent| format!("{agent} done")) - .unwrap_or_else(|| toast.title.clone()), - ToastKind::UpdateInstalled => "update ready".to_string(), - } -} - -fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - buf[(x, y)].set_symbol(" "); - buf[(x, y)].set_style(style); - } - } -} - -fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { - if area.width == 0 || area.height == 0 { - return; - } - let buf = frame.buffer_mut(); - for x in area.x..area.x + area.width { - buf[(x, area.y)] - .set_symbol("─") - .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn agent_entry(primary_tab_label: Option<&str>, agent_label: Option<&str>) -> AgentPanelEntry { - AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: PaneId::from_raw(1), - primary_label: "herdr".into(), - primary_tab_label: primary_tab_label.map(str::to_string), - pane_label: None, - terminal_title: None, - terminal_title_stripped: None, - agent_label: agent_label.map(str::to_string), - agent_kind_label: agent_label.map(str::to_string), - agent: agent_label.and_then(crate::detect::parse_agent_label), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), - tokens: std::collections::HashMap::new(), - } - } - - #[test] - fn global_agent_counts_ignore_active_agent_view_filter() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - crate::workspace::Workspace::test_new("blocked"), - crate::workspace::Workspace::test_new("working"), - ]; - app.ensure_test_terminals(); - for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] { - let pane_id = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = state; - } - app.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - let counts = global_agent_counts(&app); - assert_eq!(counts.blocked, 1); - assert_eq!(counts.working, 1); - } - - #[test] - fn agent_summary_leads_with_attention_states_in_priority_order() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let segments = agent_summary_segments(counts, StatusIndicatorStyle::Dots); - let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!( - labels, - vec!["◉ 2 blocked", "● 1 done", "2 working", "1 idle"] - ); - assert_eq!(segments[0].1, SummaryTone::Blocked); - } - - #[test] - fn distinct_agent_summary_uses_configured_symbols_for_every_state() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let labels: Vec = agent_summary_segments(counts, StatusIndicatorStyle::Symbols) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"] - ); - } - - #[test] - fn distinct_status_style_updates_mobile_blocked_badge() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")]; - app.ensure_test_terminals(); - app.status_indicators = StatusIndicatorStyle::Symbols; - let pane_id = app.workspaces[0].tabs[0].root_pane; - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(crate::detect::Agent::Claude); - terminal_state.state = AgentState::Blocked; - - let area = Rect::new(0, 0, 12, 2); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height)) - .unwrap(); - terminal - .draw(|frame| render_switch_button(&app, frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(area.width - 1, 0)].symbol(), - "×" - ); - } - - #[test] - fn agent_summary_hides_empty_categories() { - let counts = GlobalAgentCounts { - done: 1, - working: 2, - ..Default::default() - }; - let labels: Vec = agent_summary_segments(counts, StatusIndicatorStyle::Dots) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - vec!["● 1 done".to_string(), "2 working".to_string()] - ); - } - - #[test] - fn agent_summary_collapses_to_all_idle_without_attention() { - let counts = GlobalAgentCounts { - idle: 3, - ..Default::default() - }; - assert_eq!( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - vec![("all idle".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn agent_summary_drops_least_urgent_segments_when_narrow() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 24, - ); - let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); - assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]); - assert!(truncated); - } - - #[test] - fn agent_summary_keeps_all_segments_when_wide_enough() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let (shown, truncated) = fit_summary_segments( - agent_summary_segments(counts, StatusIndicatorStyle::Dots), - 60, - ); - assert_eq!(shown.len(), 4); - assert!(!truncated); - } - - #[test] - fn agent_summary_reports_no_agents_when_empty() { - assert_eq!( - agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,), - vec![("no agents".to_string(), SummaryTone::Muted)] - ); - } - - #[test] - fn switcher_leads_with_agents_and_shifts_spaces_below() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("agents-first"); - workspace.test_add_tab(None); // two tabs -> two agent panes - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - for terminal in app.terminals.values_mut() { - terminal.agent_name = Some("pi".to_string()); - terminal.state = AgentState::Working; - } - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - assert_eq!(agent_panel_entries(&app).len(), 2); - // agents title (1) + 2 agents * 2 rows = 5, then spaces title + "new - // workspace" (2) before the first workspace ribbon at doc row 7. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7); - - let viewport = mobile_switcher_areas(&app).viewport; - app.mobile_switcher_scroll = 100; - let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1); - assert!(matches!( - agent_hit, - Some(MobileSwitcherTarget::Agent { .. }) - )); - let workspace_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 7); - assert_eq!(workspace_hit, Some(MobileSwitcherTarget::Workspace(0))); - } - - fn worktree_workspace(name: &str, key: &str, linked: bool) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: linked, - }); - ws - } - - #[test] - fn switcher_spaces_follow_grouped_worktree_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - worktree_workspace("main", "repo-key", false), - crate::workspace::Workspace::test_new("other"), - worktree_workspace("feature", "repo-key", true), - ]; - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - // Grouped order pulls the worktree (idx 2) up under its parent (idx 0), - // ahead of the unrelated "other" workspace (idx 1): rows are main, - // feature, other. - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 1).start, 6); - - let viewport = mobile_switcher_areas(&app).viewport; - // The second space row on screen is the worktree, not workspaces[1]. - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - - // Mobile ignores collapse: even with the space folded on desktop, the - // worktree child still renders in the same position. - app.collapsed_space_keys.insert("repo-key".to_string()); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); - let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); - assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); - } - - #[test] - fn switcher_without_agents_keeps_spaces_first() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("shell-only")]; - app.active = Some(0); - app.selected = 0; - - // No attached terminals -> no agents -> no agents header, spaces lead. - assert_eq!(agent_panel_entries(&app).len(), 0); - assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 2); - } - - #[test] - fn mobile_agent_detail_includes_tab_context_when_available() { - let entry = agent_entry(Some("mobile-state"), Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " mobile-state · idle · pi"); - } - - #[test] - fn mobile_agent_detail_keeps_existing_compact_detail_without_tab_context() { - let entry = agent_entry(None, Some("pi")); - - assert_eq!(mobile_agent_detail(&entry), " idle · pi"); - } - - #[test] - fn mobile_tab_status_uses_compact_tab_label_and_position() { - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - workspace.active_tab = 1; - - assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2"); - } - - #[test] - fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); - let removed_tab = workspace.test_add_tab(None); - workspace.test_add_tab(None); - assert!(workspace.close_tab(removed_tab)); - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); - app.view.terminal_area = Rect::new(0, 2, 40, 18); - - let backend = ratatui::backend::TestBackend::new(40, 20); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_panel( - &app, - &TerminalRuntimeRegistry::new(), - frame, - Rect::new(0, 0, 40, 20), - ) - }) - .unwrap(); - - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) - .collect::(); - - assert!(row.contains("tab 2"), "mobile tab row: {row:?}"); - assert!(!row.contains("tab 3"), "mobile tab row: {row:?}"); - } - - #[cfg(unix)] - #[tokio::test] - async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-mobile-header-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = crate::workspace::Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - app.active = Some(0); - app.selected = 0; - app.view.mobile_menu_hit_area = Rect::new(30, 0, 10, 2); - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let backend = ratatui::backend::TestBackend::new(40, 2); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - render_mobile_header(&app, &runtime_registry, frame, Rect::new(0, 0, 40, 2)) - }) - .unwrap(); - let row = (0..40) - .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) - .collect::(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert!(row.contains("herdr"), "header row: {row:?}"); - assert!( - !row.contains("issue-264-nix-support"), - "header row: {row:?}" - ); - } -} diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs deleted file mode 100644 index cb2f235e..00000000 --- a/src/ui/navigator.rs +++ /dev/null @@ -1,643 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Clear, Paragraph}, - Frame, -}; - -use super::{ - scrollbar::{render_scrollbar, should_show_scrollbar}, - status::{state_icon, state_label_color}, - text::{display_width_u16, middle_elide, truncate_end}, - widgets::{panel_contrast_fg, render_panel_shell}, -}; -use crate::app::state::{ - navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter, - NavigatorTarget, -}; -use crate::terminal::TerminalRuntimeRegistry; - -pub(super) fn render_navigator_overlay( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, -) { - let popup = app.navigator_popup_rect(); - let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg) - else { - return; - }; - - let search = app.navigator_search_rect(); - let body = app.navigator_body_rect(); - let detail = app.navigator_detail_rect(); - let footer = app.navigator_footer_rect(); - render_search(app, frame, search); - - if body.height > 0 { - let rows = app.navigator_rows_from(terminal_runtimes); - let lines = navigator_display_lines(&rows); - render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app); - render_rows(app, &rows, &lines, frame, body); - render_navigator_scrollbar(app, lines.len(), frame, body); - } - render_detail(app, terminal_runtimes, frame, detail); - render_footer(app, frame, footer); -} - -fn render_search(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let focus_style = if app.navigator.search_focused { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - let count = app - .workspaces - .iter() - .flat_map(|workspace| workspace.tabs.iter()) - .map(|tab| tab.panes.len()) - .sum::(); - let mut spans = vec![Span::styled(" / ", focus_style)]; - let query = app.navigator.query.trim(); - match app.navigator.state_filter { - Some(NavigatorStateFilter::Blocked) => push_state_chip( - &mut spans, - crate::detect::AgentState::Blocked, - true, - "blocked", - app, - ), - Some(NavigatorStateFilter::Working) => push_state_chip( - &mut spans, - crate::detect::AgentState::Working, - true, - "working", - app, - ), - Some(NavigatorStateFilter::Idle) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - true, - "idle", - app, - ), - Some(NavigatorStateFilter::Done) => push_state_chip( - &mut spans, - crate::detect::AgentState::Idle, - false, - "done", - app, - ), - None if query.is_empty() => spans.push(Span::styled( - "search panes", - Style::default().fg(p.overlay0), - )), - None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))), - } - spans.push(Span::styled( - format!( - "{count:>width$} panes", - width = area.width.saturating_sub(16) as usize - ), - Style::default().fg(p.overlay0), - )); - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn push_state_chip( - spans: &mut Vec>, - state: crate::detect::AgentState, - seen: bool, - label: &'static str, - app: &AppState, -) { - let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette); - spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - label, - Style::default() - .fg(state_label_color(state, seen, &app.palette)) - .add_modifier(Modifier::BOLD), - )); -} - -fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) { - if area.height == 0 || area.width == 0 { - return; - } - let line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(line).style(Style::default().fg(app.palette.surface1)), - area, - ); -} - -fn render_rows( - app: &AppState, - rows: &[NavigatorRow], - lines: &[NavigatorDisplayLine], - frame: &mut Frame, - body: Rect, -) { - let start = app.navigator.scroll.min(lines.len()); - let end = lines.len().min(start.saturating_add(body.height as usize)); - for (visible_idx, line) in lines[start..end].iter().enumerate() { - let NavigatorDisplayLine::Row(idx) = *line else { - continue; - }; - let y = body.y + visible_idx as u16; - let rect = Rect::new(body.x, y, body.width, 1); - let selected = idx == app.navigator.selected; - render_row(app, frame, rect, rows, idx, selected); - } -} - -fn render_row( - app: &AppState, - frame: &mut Frame, - rect: Rect, - rows: &[NavigatorRow], - idx: usize, - selected: bool, -) { - let row = &rows[idx]; - let p = &app.palette; - frame.render_widget(Clear, rect); - let base_style = if selected { - Style::default().bg(p.accent).fg(panel_contrast_fg(p)) - } else { - Style::default().bg(p.panel_bg).fg(p.text) - }; - let dim_style = if selected { - base_style - } else { - Style::default().fg(p.overlay0).bg(p.panel_bg) - }; - let filter_active = - app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty(); - let context_only = filter_active && !row.matched; - let text_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg); - if row.is_workspace { - dimmed.add_modifier(Modifier::BOLD) - } else { - dimmed - } - } else if row.is_workspace { - Style::default() - .fg(p.accent) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else if row.is_current { - Style::default() - .fg(p.text) - .bg(p.panel_bg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).bg(p.panel_bg) - }; - let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p); - let status_style = if selected { - base_style.add_modifier(Modifier::BOLD) - } else if context_only { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - status_style.bg(p.panel_bg) - }; - - let prefix = tree_prefix(rows, idx); - let current = if row.is_current { "◆" } else { " " }; - let gutter = format!(" {current} "); - let gutter_style = if selected { - base_style - } else if row.is_current { - Style::default().fg(p.accent).bg(p.panel_bg) - } else { - dim_style - }; - // Branch glyphs recede one shade below the workspace caret so the - // structure stays behind the labels. - let tree_style = if selected { - base_style - } else if row.is_workspace { - dim_style - } else { - Style::default().fg(p.surface1).bg(p.panel_bg) - }; - let meta_width = metadata_width(rect.width); - let left_budget = rect - .width - .saturating_sub(meta_width) - .saturating_sub(display_width_u16(&format!("{gutter}{prefix} "))) - .saturating_sub(3) as usize; - let title = truncate_end(&row.label, left_budget); - - let spans = vec![ - Span::styled(gutter, gutter_style), - Span::styled(prefix, tree_style), - Span::styled(" ", base_style), - Span::styled(status_icon, status_style), - Span::raw(" "), - Span::styled(title, text_style), - ]; - frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect); - - if meta_width > 0 { - let meta_rect = Rect::new( - rect.x + rect.width.saturating_sub(meta_width), - rect.y, - meta_width, - 1, - ); - let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); - let meta_style = if selected { - base_style - } else if context_only || row.is_workspace || row.is_tab { - Style::default().fg(p.overlay0).bg(p.panel_bg) - } else { - Style::default() - .fg(state_label_color(row.status, row.seen, p)) - .bg(p.panel_bg) - }; - frame.render_widget( - Paragraph::new(format!(" {meta}")).style(meta_style), - meta_rect, - ); - } -} - -/// Tree prefix for a navigator row: expand caret for workspaces, connected -/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│` -/// continuation lines under ancestors that have more siblings below). -fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String { - let row = &rows[idx]; - if row.is_workspace { - return if row.expanded { "▾" } else { "▸" }.to_string(); - } - if row.depth == 0 { - return " ".to_string(); - } - let mut prefix = String::new(); - for level in 1..row.depth { - prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) { - "│ " - } else { - " " - }); - } - prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) { - "├──" - } else { - "└──" - }); - prefix -} - -/// Whether another row at `depth` follows `idx` before the subtree at that -/// depth ends (a row shallower than `depth` closes the subtree). -fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool { - rows[idx + 1..] - .iter() - .take_while(|row| row.depth >= depth) - .any(|row| row.depth == depth) -} - -fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) { - if body.width <= 1 || body.height == 0 { - return; - } - let viewport = body.height as usize; - if line_count <= viewport { - return; - } - let metrics = crate::pane::ScrollMetrics { - viewport_rows: viewport, - offset_from_bottom: line_count - .saturating_sub(viewport) - .saturating_sub(app.navigator.scroll), - max_offset_from_bottom: line_count.saturating_sub(viewport), - }; - if !should_show_scrollbar(metrics) { - return; - } - let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height); - render_scrollbar( - frame, - metrics, - track, - app.palette.surface_dim, - app.palette.overlay0, - "▕", - ); -} - -fn metadata_width(width: u16) -> u16 { - if width >= 90 { - 28 - } else if width >= 68 { - 20 - } else if width >= 52 { - 14 - } else { - 0 - } -} - -fn render_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - if area.height == 0 || area.width == 0 { - return; - } - render_separator(frame, area, app); - let detail = selected_detail(app, terminal_runtimes); - if detail.is_empty() { - return; - } - let text = middle_elide(&detail, area.width.saturating_sub(2) as usize); - frame.render_widget( - Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)), - area, - ); -} - -fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) -> String { - let rows = app.navigator_rows_from(terminal_runtimes); - let Some(row) = rows.get(app.navigator.selected) else { - return String::new(); - }; - match row.target { - NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx), - NavigatorTarget::Tab { ws_idx, tab_idx } => { - tab_detail(app, terminal_runtimes, ws_idx, tab_idx) - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id), - } -} - -fn workspace_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::(); - let mut parts = vec![label, format!("{pane_count} panes")]; - if !rowless_workspace_activity(app, terminal_runtimes, ws_idx).is_empty() { - parts.push(rowless_workspace_activity(app, terminal_runtimes, ws_idx)); - } - parts.join(" · ") -} - -fn tab_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ - ws.display_name_from(&app.terminals, terminal_runtimes), - format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - ), - format!("{} panes", tab.panes.len()), - ]; - let rows = app.navigator_rows_from(terminal_runtimes); - if let Some(meta) = rows - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx)) - .map(|row| row.meta) - .filter(|meta| !meta.is_empty()) - { - parts.push(meta); - } - parts.join(" · ") -} - -fn pane_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> String { - let Some(ws) = app.workspaces.get(ws_idx) else { - return String::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return String::new(); - }; - let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)]; - if ws.tabs.len() > 1 { - parts.push(format!( - "tab: {}", - ws.tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()) - )); - } - if let Some(pane_number) = ws.public_pane_number(pane_id) { - parts.push(format!("pane {pane_number}")); - } - if let Some(terminal_id) = tab.terminal_id(pane_id) { - if let Some(terminal) = app.terminals.get(terminal_id) { - let presentation = terminal.effective_presentation(); - if let Some(title) = presentation.title { - parts.push(title); - } - let display_agent = terminal.effective_display_agent(); - if let Some(agent) = display_agent.as_deref().or_else(|| { - terminal - .agent_name - .as_deref() - .or_else(|| terminal.effective_agent_label()) - }) { - parts.push(agent.to_string()); - let seen = tab - .panes - .get(&pane_id) - .map(|pane| pane.seen) - .unwrap_or(true); - let state = row_state(app, ws_idx, tab_idx, pane_id); - let status = presentation - .state_labels - .get(display_state(state, seen)) - .cloned() - .unwrap_or_else(|| display_state(state, seen).to_string()); - parts.push(status); - } else { - parts.push("shell".to_string()); - } - } - } - parts.join(" · ") -} - -fn rowless_workspace_activity( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> String { - app.navigator_rows_from(terminal_runtimes) - .into_iter() - .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx)) - .map(|row| row.meta) - .unwrap_or_default() -} - -fn row_state( - app: &AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: crate::layout::PaneId, -) -> crate::detect::AgentState { - app.workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .and_then(|tab| tab.terminal_id(pane_id)) - .and_then(|terminal_id| app.terminals.get(terminal_id)) - .map(|terminal| terminal.state) - .unwrap_or(crate::detect::AgentState::Unknown) -} - -fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => "blocked", - (crate::detect::AgentState::Working, _) => "working", - (crate::detect::AgentState::Idle, false) => "done", - (crate::detect::AgentState::Idle, true) => "idle", - (crate::detect::AgentState::Unknown, _) => "unknown", - } -} - -fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { - if area.height == 0 { - return; - } - let p = &app.palette; - let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(p.overlay0); - let line = if app.navigator.search_focused { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("↑↓", key), - Span::styled(" move ", dim), - Span::styled("ctrl+u", key), - Span::styled(" clear ", dim), - Span::styled("esc", key), - Span::styled(" back", dim), - ]) - } else { - Line::from(vec![ - Span::styled(" enter", key), - Span::styled(" switch ", dim), - Span::styled("/", key), - Span::styled(" search ", dim), - Span::styled("b/w/i/d/a", key), - Span::styled(" states ", dim), - Span::styled("j/k/↑↓", key), - Span::styled(" move ", dim), - Span::styled("esc", key), - Span::styled(" close", dim), - ]) - }; - frame.render_widget(Paragraph::new(line), area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::detect::AgentState; - - fn row(depth: u8, is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth, - label: String::new(), - meta: String::new(), - status: AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - fn multi_tab_rows() -> Vec { - vec![ - row(0, true), // workspace - row(1, false), // tab a - row(2, false), // pane - row(2, false), // pane (last in tab a) - row(1, false), // tab b (last tab) - row(2, false), // pane (last in tab b) - row(0, true), // workspace - row(1, false), // pane (single child) - ] - } - - #[test] - fn workspace_rows_use_expand_caret() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 0), "▾"); - let mut collapsed = rows.clone(); - collapsed[0].expanded = false; - assert_eq!(tree_prefix(&collapsed, 0), "▸"); - } - - #[test] - fn middle_children_get_branch_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 1), "├──"); - assert_eq!(tree_prefix(&rows, 2), "│ ├──"); - } - - #[test] - fn last_children_get_terminator_glyph() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 3), "│ └──"); - assert_eq!(tree_prefix(&rows, 4), "└──"); - assert_eq!(tree_prefix(&rows, 7), "└──"); - } - - #[test] - fn spine_stops_after_last_ancestor_sibling() { - let rows = multi_tab_rows(); - assert_eq!(tree_prefix(&rows, 5), " └──"); - } - - #[test] - fn next_workspace_does_not_extend_previous_subtree() { - // The pane at idx 5 is last in its workspace even though another - // workspace with children follows. - let rows = multi_tab_rows(); - assert!(!has_following_sibling_at_depth(&rows, 5, 1)); - assert!(!has_following_sibling_at_depth(&rows, 5, 2)); - } -} diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 8829acc7..94c9b2a0 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -1,114 +1,19 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, -}; +use ratatui::layout::Rect; -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_shell, -}; -use crate::app::AppState; - -const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; - -pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - super::dim_background(frame, area); - render_onboarding_welcome(app, frame, area); -} +pub(crate) const ONBOARDING_TITLE: &str = " herdr"; +pub(crate) const ONBOARDING_SUBTITLE: &str = " terminal workspace manager for coding agents"; +pub(crate) const ONBOARDING_DESCRIPTION: [&str; 3] = [ + " this is a mouse-first terminal.", + " click the sidebar to switch workspaces, drag pane", + " borders to resize, right-click for context menus.", +]; +pub(crate) const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; +pub(crate) const ONBOARDING_PREFIX_SUFFIX: &str = " enters prefix mode · "; +pub(crate) const ONBOARDING_HELP_LABEL: &str = "?"; +pub(crate) const ONBOARDING_HELP_SUFFIX: &str = " shows keybinds and settings"; +pub(crate) const ONBOARDING_NEXT: &str = + " next: install optional agent integrations for more reliable state"; pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { - Rect::new( - area.x, - area.y, - action_button_width(Some("↵"), "continue"), - 1, - ) -} - -fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { - return; - }; - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 2, 0, 1, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - let content_rows = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<4>(stack.content); - - frame.render_widget( - Paragraph::new(" herdr").style( - Style::default() - .fg(app.palette.text) - .add_modifier(Modifier::BOLD), - ), - header_rows[0], - ); - frame.render_widget( - Paragraph::new(" terminal workspace manager for coding agents") - .style(Style::default().fg(app.palette.overlay0)), - header_rows[1], - ); - - frame.render_widget( - Paragraph::new( - " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.", - ) - .style(Style::default().fg(app.palette.overlay1)), - content_rows[0], - ); - - let key_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - ONBOARDING_PREFIX_LABEL, - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " enters prefix mode · ", - Style::default().fg(app.palette.overlay1), - ), - Span::styled( - "?", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " shows keybinds and settings", - Style::default().fg(app.palette.overlay1), - ), - ]); - frame.render_widget(Paragraph::new(key_line), content_rows[2]); - - frame.render_widget( - Paragraph::new(" next: install optional agent integrations for more reliable state") - .style(Style::default().fg(app.palette.overlay1)), - content_rows[3], - ); - - let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); - render_action_button( - frame, - continue_rect, - Some("↵"), - "continue", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); + super::widgets::continue_button_rect(area) } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index f20b55b5..cd5ea34c 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -1,8 +1,8 @@ use ratatui::{ + buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders}, Frame, }; @@ -12,7 +12,7 @@ use super::text::display_width; use super::text::truncate_end; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::PaneInfo; use crate::popup_size::resolve_popup_geometry; use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; @@ -352,60 +352,13 @@ pub(super) fn render_panes( return; }; - let multi_pane = ws.layout.pane_count() > 1; - let terminal_active = app.mode == Mode::Terminal; - for info in pane_infos { if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { let show_cursor = info.is_focused - && terminal_active && !pane_is_scrolled_back(rt) && app.pane_exposes_host_cursor(ws_idx, info.id); rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); - - let should_dim = !info.is_focused && multi_pane && !terminal_active; - if should_dim { - let inner = info.inner_rect; - let buf = frame.buffer_mut(); - for y in inner.y..inner.y + inner.height { - for x in inner.x..inner.x + inner.width { - let cell = &mut buf[(x, y)]; - cell.set_style(cell.style().add_modifier(Modifier::DIM)); - } - } - } - - let (copy_search_top, copy_search_bottom, copy_search_matches) = - validated_copy_mode_search_matches(app, info, rt); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - false, - ); - render_selection_highlight( - &app.selection, - frame, - info.id, - info.inner_rect, - rt.scroll_metrics(), - &app.palette, - app.host_terminal_theme, - ); - render_copy_mode_search_highlights( - app, - frame, - info, - copy_search_top, - copy_search_bottom, - ©_search_matches, - true, - ); - render_copy_mode_cursor(app, frame, info); } } @@ -443,36 +396,6 @@ pub(super) fn resize_popup_pane( } } -pub(super) fn render_popup_pane( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let Some(popup) = app.popup_pane.as_ref() else { - return; - }; - let Some((outer, inner)) = popup_pane_rects(app, area) else { - return; - }; - let Some(rt) = terminal_runtimes.get(&popup.terminal_id) else { - return; - }; - let title = app - .terminals - .get(&popup.terminal_id) - .and_then(|terminal| terminal.manual_label.as_deref()) - .unwrap_or("popup"); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.palette.accent)) - .title(pane_border_title(title, outer.width, true).unwrap_or_default()) - .style(Style::default().bg(app.palette.panel_bg)); - frame.render_widget(Clear, outer); - frame.render_widget(block, outer); - rt.render(frame, inner, !pane_is_scrolled_back(rt)); -} - #[derive(Clone, Copy, Default)] struct LineCell { up: bool, @@ -724,141 +647,25 @@ fn line_cell_symbol(line: LineCell) -> &'static str { } } -fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { - if app.mode != Mode::Copy { - return; - } - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - if copy_mode.pane_id != info.id - || copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let x = info.inner_rect.x + copy_mode.cursor_col; - let y = info.inner_rect.y + copy_mode.cursor_row; - let cell = &mut frame.buffer_mut()[(x, y)]; - cell.set_style( - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} - -fn validated_copy_mode_search_matches( - app: &AppState, - info: &PaneInfo, - rt: &crate::terminal::TerminalRuntime, -) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return (0, 0, Vec::new()); - }; - if copy_mode.pane_id != info.id { - return (0, 0, Vec::new()); - } - let Some(metrics) = rt.scroll_metrics() else { - return (0, 0, Vec::new()); - }; - let top = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32; - let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1))); - let first_visible = copy_mode - .search - .matches - .partition_point(|text_match| text_match.end.row < top); - let visible = ©_mode.search.matches[first_visible..]; - let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom); - let candidates = visible[..visible_len].to_vec(); - let validity = rt.text_matches_are_current(&candidates); - - let matches = candidates - .into_iter() - .zip(validity) - .enumerate() - .filter_map(|(offset, (text_match, is_current))| { - is_current.then_some((first_visible + offset, text_match)) - }) - .collect(); - (top, bottom, matches) -} - -fn render_copy_mode_search_highlights( - app: &AppState, - frame: &mut Frame, - info: &PaneInfo, - top: u32, - bottom: u32, - matches: &[(usize, crate::pane::TerminalTextMatch)], - current_only: bool, -) { - let Some(copy_mode) = app.copy_mode.as_ref() else { - return; - }; - let current = copy_mode.search.current; - let style = if current_only { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface1) - }; - - for &(index, text_match) in matches { - if (current == Some(index)) != current_only { - continue; - } - let start_row = text_match.start.row.max(top); - let end_row = text_match.end.row.min(bottom); - for absolute_row in start_row..=end_row { - let viewport_row = absolute_row.saturating_sub(top) as u16; - let start_col = if absolute_row == text_match.start.row { - text_match.start.col - } else { - 0 - }; - let end_col = if absolute_row == text_match.end.row { - text_match.end.col - } else { - info.inner_rect.width.saturating_sub(1) - }; - for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) { - let x = info.inner_rect.x.saturating_add(col); - let y = info.inner_rect.y.saturating_add(viewport_row); - frame.buffer_mut()[(x, y)].set_style(style); - } - } - } -} - -fn render_selection_highlight( - selection: &Option, - frame: &mut Frame, - pane_id: crate::layout::PaneId, +pub(crate) fn render_selection_highlight( + selection: Option<&crate::selection::Selection

>, + buffer: &mut Buffer, + pane_id: &P, inner: Rect, scroll_metrics: Option, p: &Palette, host_theme: crate::terminal_theme::TerminalTheme, ) { - if let Some(sel) = selection { - if sel.is_visible() && sel.pane_id == pane_id { - let buf = frame.buffer_mut(); - let style = automatic_selection_style(p, host_theme); - for y in 0..inner.height { - for x in 0..inner.width { - if sel.contains(y, x, scroll_metrics) { - let cell = &mut buf[(inner.x + x, inner.y + y)]; - cell.set_style(style); - } - } + let Some(selection) = + selection.filter(|selection| selection.is_visible() && &selection.pane_id == pane_id) + else { + return; + }; + let style = automatic_selection_style(p, host_theme); + for y in 0..inner.height { + for x in 0..inner.width { + if selection.contains(y, x, scroll_metrics) { + buffer[(inner.x + x, inner.y + y)].set_style(style); } } } @@ -959,47 +766,6 @@ fn color_to_rgb(color: Color) -> Option { } } -pub(super) fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - let lines = vec![ - Line::from(""), - Line::from(""), - Line::from(Span::styled( - " No workspaces yet", - Style::default().fg(p.overlay0), - )), - Line::from(""), - Line::from(Span::styled( - " A workspace is one project context.", - Style::default().fg(p.overlay1), - )), - Line::from(Span::styled( - " Its root pane (top-left) sets the default repo or folder name.", - Style::default().fg(p.overlay1), - )), - Line::from(""), - Line::from(vec![ - Span::styled(" Press ", Style::default().fg(p.overlay0)), - Span::styled( - app.keybinds - .new_workspace - .label() - .unwrap_or_else(|| "unset".to_string()), - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" to create one", Style::default().fg(p.overlay0)), - ]), - ]; - frame.render_widget( - Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.surface_dim)), - ), - area, - ); -} - #[cfg(test)] mod tests { use super::*; @@ -1009,14 +775,13 @@ mod tests { use crate::terminal::TerminalState; use crate::workspace::Workspace; - fn render_view_pane_borders(app: &AppState, ws: &Workspace, frame: &mut Frame) { - render_pane_borders( - app, - ws, - &app.view.pane_infos, - &app.view.split_borders, - frame, - ); + fn render_view_pane_borders( + app: &AppState, + ws: &Workspace, + split_borders: &[crate::layout::SplitBorder], + frame: &mut Frame, + ) { + render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame); } #[test] @@ -1052,7 +817,6 @@ mod tests { #[test] fn pane_border_renderer_places_adjacent_cjk_by_display_width() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 12, 3); let ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; @@ -1073,7 +837,7 @@ mod tests { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1208,7 +972,6 @@ mod tests { #[test] fn global_pane_border_renderer_composes_junctions_and_focus_style() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.view.terminal_area = Rect::new(0, 0, 4, 4); app.view.pane_infos = vec![ PaneInfo { @@ -1244,7 +1007,7 @@ mod tests { is_focused: false, }, ]; - app.view.split_borders = vec![ + let split_borders = vec![ crate::layout::SplitBorder { pos: 2, direction: ratatui::layout::Direction::Horizontal, @@ -1265,7 +1028,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &split_borders, frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1278,7 +1041,6 @@ mod tests { #[test] fn gapped_pane_focus_does_not_color_neighbor_border() { let mut app = AppState::test_new(); - app.mode = Mode::Terminal; app.pane_gaps = true; app.view.terminal_area = Rect::new(0, 0, 4, 3); app.view.pane_infos = vec![ @@ -1304,7 +1066,7 @@ mod tests { ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap(); terminal - .draw(|frame| render_view_pane_borders(&app, &ws, frame)) + .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame)) .unwrap(); let buffer = terminal.backend().buffer(); @@ -1532,7 +1294,11 @@ mod tests { ..Default::default() }; let expected_style = automatic_selection_style(&palette, host_theme); - let selection = Some(Selection::range(PaneId::from_raw(1), 0, 0, 2, None)); + let selection = Some(Selection::absolute_range( + PaneId::from_raw(1), + (0, 0), + (0, 2), + )); let backend = ratatui::backend::TestBackend::new(4, 1); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -1552,9 +1318,9 @@ mod tests { ); buf[(2, 0)].set_style(Style::default().fg(Color::Blue).bg(Color::Reset)); render_selection_highlight( - &selection, - frame, - PaneId::from_raw(1), + selection.as_ref(), + frame.buffer_mut(), + &PaneId::from_raw(1), Rect::new(0, 0, 4, 1), None, &palette, diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index 5274739c..5f6dc078 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -1,250 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Paragraph, Wrap}, - Frame, }; -use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; -use super::widgets::{ - action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, - render_modal_header, render_modal_shell, -}; -use crate::app::{ - state::{Palette, ProductAnnouncementState, ReleaseNotesState}, - AppState, -}; +use crate::app::state::{Palette, ProductAnnouncementState, ReleaseNotesState}; pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24); -pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(notes) = &app.release_notes else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - RELEASE_NOTES_MODAL_SIZE.0, - RELEASE_NOTES_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header( - frame, - header_title_area, - &format!("v{}", notes.version), - &app.palette, - ); - let subtitle = if notes.preview { - "update ready" - } else { - "what's new in this release" - }; - frame.render_widget( - Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let notes_body = stack.content; - let display_lines = - release_notes_display_lines(notes, &app.update_install_command, &app.palette); - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize, - max_offset_from_bottom: app.release_notes_max_scroll() as usize, - viewport_rows: notes_body.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(notes_body, metrics); - let notes_text_area = track - .map(|_| { - Rect::new( - notes_body.x, - notes_body.y, - notes_body.width.saturating_sub(1), - notes_body.height, - ) - }) - .unwrap_or(notes_body); - - let body = Paragraph::new( - display_lines - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((notes.scroll, 0)); - frame.render_widget(body, notes_text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - -pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(announcement) = &app.product_announcement else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_modal_shell( - frame, - area, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - &app.palette, - ) else { - return; - }; - if inner.height < 8 || inner.width < 20 { - return; - } - - let stack = modal_stack_areas(inner, 2, 1, 0, 1); - let header_rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header); - - let header_title_area = Rect::new( - header_rows[0].x + 1, - header_rows[0].y, - header_rows[0].width.saturating_sub(2), - header_rows[0].height, - ); - let header_subtitle_area = Rect::new( - header_rows[1].x + 1, - header_rows[1].y, - header_rows[1].width.saturating_sub(2), - header_rows[1].height, - ); - - render_modal_header(frame, header_title_area, &announcement.title, &app.palette); - let subtitle = if announcement.preview { - "product announcement preview" - } else { - "product announcement" - }; - frame.render_widget( - Paragraph::new(format!("{subtitle} · v{}", announcement.version)) - .style(Style::default().fg(app.palette.overlay1)), - header_subtitle_area, - ); - render_action_button( - frame, - release_notes_close_button_rect(header_rows[0]), - Some("esc"), - "close", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); - - let body_rect = stack.content; - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: app - .product_announcement_max_scroll() - .saturating_sub(announcement.scroll) as usize, - max_offset_from_bottom: app.product_announcement_max_scroll() as usize, - viewport_rows: body_rect.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(body_rect, metrics); - let text_area = track - .map(|_| { - Rect::new( - body_rect.x, - body_rect.y, - body_rect.width.saturating_sub(1), - body_rect.height, - ) - }) - .unwrap_or(body_rect); - - let body = Paragraph::new( - product_announcement_display_lines(announcement, &app.palette) - .into_iter() - .map(|(_, line)| line) - .collect::>(), - ) - .wrap(Wrap { trim: false }) - .scroll((announcement.scroll, 0)); - frame.render_widget(body, text_area); - if let Some(track) = track { - render_scrollbar( - frame, - metrics, - track, - app.palette.overlay0, - app.palette.overlay1, - "▐", - ); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)), - Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)), - Span::styled(" · ", Style::default().fg(app.palette.overlay0)), - Span::styled("close", Style::default().fg(app.palette.overlay0)), - Span::styled(" esc / enter ", Style::default().fg(app.palette.text)), - ])), - stack.footer.unwrap_or_default(), - ); -} - fn release_notes_inline_spans<'a>( text: &str, base_style: Style, @@ -428,6 +193,53 @@ pub(crate) fn product_announcement_display_lines<'a>( release_notes_lines(announcement.body.as_str(), p) } +fn display_lines_scroll_metrics( + lines: &[(usize, Line<'_>)], + scroll: u16, + body: Rect, +) -> crate::pane::ScrollMetrics { + let viewport_rows = body.height.max(1) as usize; + let rows_for_width = |width: u16| release_notes_wrapped_line_count(lines, width.max(1)); + let full_width = body.width.max(1); + let initial_rows = rows_for_width(full_width); + let wrap_width = if initial_rows > viewport_rows && full_width > 1 { + full_width.saturating_sub(1) + } else { + full_width + }; + let max_offset_from_bottom = rows_for_width(wrap_width).saturating_sub(viewport_rows); + crate::pane::ScrollMetrics { + offset_from_bottom: max_offset_from_bottom.saturating_sub(usize::from(scroll)), + max_offset_from_bottom, + viewport_rows, + } +} + +pub(crate) fn release_notes_scroll_metrics( + notes: &ReleaseNotesState, + install_command: &str, + body: Rect, + p: &Palette, +) -> crate::pane::ScrollMetrics { + display_lines_scroll_metrics( + &release_notes_display_lines(notes, install_command, p), + notes.scroll, + body, + ) +} + +pub(crate) fn product_announcement_scroll_metrics( + announcement: &ProductAnnouncementState, + body: Rect, + p: &Palette, +) -> crate::pane::ScrollMetrics { + display_lines_scroll_metrics( + &product_announcement_display_lines(announcement, p), + announcement.scroll, + body, + ) +} + pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], width: u16) -> usize { Paragraph::new( lines @@ -440,8 +252,7 @@ pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], widt } pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect { - let width = action_button_width(Some("esc"), "close"); - Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) + super::widgets::close_button_rect(area) } #[cfg(test)] diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index b73b2c50..cf700825 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -1,4 +1,5 @@ use ratatui::{ + buffer::Buffer, layout::Rect, style::{Color, Style}, Frame, @@ -75,7 +76,7 @@ pub(crate) fn scrollbar_thumb_grab_offset( row: u16, ) -> Option { let thumb = scrollbar_thumb(metrics, track)?; - (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top) + (row >= thumb.top && row < thumb.top + thumb.len).then(|| row - thumb.top) } fn scrollbar_offset_from_thumb_top( @@ -132,8 +133,8 @@ pub(crate) fn scrollbar_offset_from_drag_row( scrollbar_offset_from_thumb_top(metrics, track, desired_top) } -pub(super) fn render_scrollbar( - frame: &mut Frame, +pub(crate) fn render_scrollbar_buffer( + buffer: &mut Buffer, metrics: crate::pane::ScrollMetrics, track: Rect, track_color: Color, @@ -148,19 +149,40 @@ pub(super) fn render_scrollbar( return; }; - let buf = frame.buffer_mut(); for y in track.y..track.y + track.height { - let cell = &mut buf[(track.x, y)]; + let cell = &mut buffer[(track.x, y)]; cell.set_symbol("▕"); cell.set_style(Style::default().fg(track_color)); } for y in thumb.top..thumb.top + thumb.len { - let cell = &mut buf[(track.x, y)]; + let cell = &mut buffer[(track.x, y)]; cell.set_symbol(thumb_symbol); cell.set_style(Style::default().fg(thumb_color)); } } +pub(crate) fn render_pane_scrollbar_buffer( + buffer: &mut Buffer, + metrics: crate::pane::ScrollMetrics, + track: Rect, + palette: &crate::app::state::Palette, + focused: bool, +) { + let (track_color, thumb_color, thumb_symbol) = if focused { + (palette.overlay0, palette.overlay1, "▐") + } else { + (palette.surface_dim, palette.overlay0, "▕") + }; + render_scrollbar_buffer( + buffer, + metrics, + track, + track_color, + thumb_color, + thumb_symbol, + ); +} + pub(super) fn render_pane_scrollbar( app: &AppState, frame: &mut Frame, @@ -173,19 +195,11 @@ pub(super) fn render_pane_scrollbar( let Some(track) = pane_scrollbar_rect(info) else { return; }; - - let (track_color, thumb_color, thumb_symbol) = if info.is_focused { - (app.palette.overlay0, app.palette.overlay1, "▐") - } else { - (app.palette.surface_dim, app.palette.overlay0, "▕") - }; - - render_scrollbar( - frame, + render_pane_scrollbar_buffer( + frame.buffer_mut(), metrics, track, - track_color, - thumb_color, - thumb_symbol, + &app.palette, + info.is_focused, ); } diff --git a/src/ui/settings.rs b/src/ui/settings.rs deleted file mode 100644 index 3cea6da3..00000000 --- a/src/ui/settings.rs +++ /dev/null @@ -1,424 +0,0 @@ -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph, Tabs}, - Frame, -}; - -use super::widgets::{ - action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, - render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, -}; -use crate::{ - app::{state::Palette, AppState}, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76; -pub(crate) const SETTINGS_POPUP_BASE_HEIGHT: u16 = 22; - -pub(crate) fn settings_popup_height(app: &AppState) -> u16 { - if app.settings.section != crate::app::state::SettingsSection::Integrations { - return SETTINGS_POPUP_BASE_HEIGHT; - } - let list_rows = app.integration_recommendations.len().max(1) as u16; - let footer_rows = integrations_footer_height(app, SETTINGS_POPUP_WIDTH - 2); - // borders 2 + header 3 + stack gaps 2 + modal footer 2 - // + section title 1 + description 2 + spacers 2 - (14 + list_rows + footer_rows).max(SETTINGS_POPUP_BASE_HEIGHT) -} - -pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::SettingsSection; - - let p = &app.palette; - let Some(popup) = centered_popup_rect(area, SETTINGS_POPUP_WIDTH, settings_popup_height(app)) - else { - return; - }; - - super::dim_background(frame, area); - - let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else { - return; - }; - if inner.height < 4 || inner.width < 10 { - return; - } - - let stack = modal_stack_areas(inner, 3, 2, 0, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " settings", - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )])), - header_rows[0], - ); - - let tab_labels = SettingsSection::ALL.iter().map(|section| { - if app.settings_section_has_badge(*section) { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::raw(section.label()), - ]) - } else { - Line::from(section.label()) - } - }); - let tabs = Tabs::new(tab_labels) - .select( - SettingsSection::ALL - .iter() - .position(|section| *section == app.settings.section) - .unwrap_or(0), - ) - .style(Style::default().fg(p.overlay1)) - .highlight_style( - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ) - .divider(" ") - .padding(" ", " "); - frame.render_widget(tabs, header_rows[1]); - - let sep = "─".repeat(inner.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), - header_rows[2], - ); - - let content_area = stack.content; - - match app.settings.section { - SettingsSection::Theme => { - render_settings_theme(app, frame, content_area); - } - SettingsSection::Indicators => { - render_modal_choice_list( - frame, - content_area, - "agent status indicators", - "choose color dots or distinct symbols for each state", - &[ - ("color dots ● ● ● ○ ·", StatusIndicatorStyle::Dots), - ("distinct symbols × ◐ ✓ ○ ·", StatusIndicatorStyle::Symbols), - ], - app.status_indicators, - app.settings.list.selected, - p, - 1, - ); - } - SettingsSection::Sound => { - render_settings_toggle( - frame, - content_area, - p, - "sound alerts", - "play sounds when agents change state in background", - app.sound_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Toast => { - render_modal_choice_list( - frame, - content_area, - "notification popups", - "choose where background popup notifications should appear", - &[ - ("off", ToastDelivery::Off), - ("inside herdr", ToastDelivery::Herdr), - ("via terminal", ToastDelivery::Terminal), - ("via system", ToastDelivery::System), - ], - app.toast_delivery(), - app.settings.list.selected, - p, - 2, - ); - } - SettingsSection::PaneLabels => { - render_settings_toggle( - frame, - content_area, - p, - "agent border labels", - "show detected agent names in split pane borders", - app.agent_border_labels_enabled(), - app.settings.list.selected, - ); - } - SettingsSection::Integrations => { - render_settings_integrations(app, frame, content_area); - } - } - - if let Some(footer_area) = stack.footer { - let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) - .areas::<2>(footer_area); - let primary_label = settings_primary_button_label(app.settings.section); - let show_primary = settings_show_primary_action(app); - let (apply_rect, close_rect) = - settings_button_rects(inner, app.settings.section, show_primary); - if let Some(apply_rect) = apply_rect { - render_action_button( - frame, - apply_rect, - Some("↵"), - primary_label, - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ); - } - render_action_button( - frame, - close_rect, - Some("esc"), - "close", - Style::default() - .fg(p.text) - .bg(p.surface0) - .add_modifier(Modifier::BOLD), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ↑↓", Style::default().fg(p.overlay0)), - Span::styled(" select ", Style::default().fg(p.overlay1)), - Span::styled("tab", Style::default().fg(p.overlay0)), - Span::styled(" section", Style::default().fg(p.overlay1)), - ])), - footer_rows[0], - ); - } -} - -pub(crate) fn settings_primary_button_label( - section: crate::app::state::SettingsSection, -) -> &'static str { - match section { - crate::app::state::SettingsSection::Integrations => "install", - _ => "apply", - } -} - -pub(crate) fn settings_show_primary_action(app: &AppState) -> bool { - match app.settings.section { - crate::app::state::SettingsSection::Integrations => app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install), - _ => true, - } -} - -pub(crate) fn settings_button_rects( - inner: Rect, - section: crate::app::state::SettingsSection, - show_primary: bool, -) -> (Option, Rect) { - if !show_primary { - let rects = action_button_row_rects( - inner, - &[ActionButtonSpec { - hint: Some("esc"), - label: "close", - }], - 2, - inner.height.saturating_sub(1), - ); - return (None, rects[0]); - } - - let rects = action_button_row_rects( - inner, - &[ - ActionButtonSpec { - hint: Some("↵"), - label: settings_primary_button_label(section), - }, - ActionButtonSpec { - hint: Some("esc"), - label: "close", - }, - ], - 2, - inner.height.saturating_sub(1), - ); - (Some(rects[0]), rects[1]) -} - -fn integrations_footer_paragraph(app: &AppState) -> Paragraph<'static> { - let p = &app.palette; - let mut footer_lines = Vec::new(); - if !app.integration_install_messages.is_empty() { - for message in &app.integration_install_messages { - footer_lines.push(Line::from(Span::styled( - format!(" {message}"), - Style::default().fg(p.overlay1), - ))); - } - } else { - let found_any = app.integration_recommendations.iter().any(|item| { - item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled - }); - let hint = if app - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) - { - " press install to add available or outdated integrations" - } else if found_any { - " all detected integrations are installed" - } else { - " no supported agent CLIs found on PATH" - }; - footer_lines.push(Line::from(Span::styled( - hint.to_string(), - Style::default().fg(p.overlay1), - ))); - } - Paragraph::new(footer_lines).wrap(ratatui::widgets::Wrap { trim: false }) -} - -fn integrations_footer_height(app: &AppState, width: u16) -> u16 { - (integrations_footer_paragraph(app).line_count(width) as u16).min(6) -} - -fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { - let p = &app.palette; - - let footer = integrations_footer_paragraph(app); - let footer_height = integrations_footer_height(app, area.width); - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(footer_height), - ]) - .areas::<6>(area); - - frame.render_widget( - Paragraph::new("agent integrations") - .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)), - rows[0], - ); - frame.render_widget( - Paragraph::new( - "let agents report state directly instead of relying only on process detection", - ) - .style(Style::default().fg(p.overlay1)) - .wrap(ratatui::widgets::Wrap { trim: false }), - rows[1], - ); - - let mut lines = Vec::new(); - for item in &app.integration_recommendations { - let marker = match item.state { - crate::integration::IntegrationStatusKind::Current => "✓", - crate::integration::IntegrationStatusKind::Outdated => "↻", - crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+", - crate::integration::IntegrationStatusKind::NotInstalled => "–", - }; - let marker_style = match item.state { - crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green), - crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow), - crate::integration::IntegrationStatusKind::NotInstalled if item.available => { - Style::default().fg(p.accent) - } - crate::integration::IntegrationStatusKind::NotInstalled => { - Style::default().fg(p.overlay0) - } - }; - lines.push(Line::from(vec![ - Span::styled(format!(" {marker} "), marker_style), - Span::styled( - format!("{:<9}", item.label), - Style::default().fg(p.subtext0), - ), - Span::styled(item.status_label(), Style::default().fg(p.overlay1)), - ])); - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled( - " no integration targets available", - Style::default().fg(p.overlay1), - ))); - } - - frame.render_widget(Paragraph::new(lines), rows[3]); - frame.render_widget(footer, rows[5]); -} - -fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { - use crate::app::state::THEME_NAMES; - - let p = &app.palette; - let items: Vec = THEME_NAMES - .iter() - .map(|name| { - let is_current = name.to_lowercase().replace([' ', '_'], "-") - == app.theme_name.to_lowercase().replace([' ', '_'], "-"); - let marker = if is_current { " ✓" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(*name, Style::default().fg(p.subtext0)), - Span::styled(marker, Style::default().fg(p.green)), - ])) - }) - .collect(); - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" ▸ ") - .style(Style::default().fg(p.subtext0)); - - let mut state = ListState::default().with_selected(Some(app.settings.list.selected)); - frame.render_stateful_widget(list, area, &mut state); -} - -fn render_settings_toggle( - frame: &mut Frame, - area: Rect, - p: &Palette, - title: &str, - description: &str, - current_value: bool, - selected_idx: usize, -) { - render_modal_choice_list( - frame, - area, - title, - description, - &[("on", true), ("off", false)], - current_value, - selected_idx, - p, - 1, - ); -} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index a6dfbcd3..3da1e9f9 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,71 +1,68 @@ mod tokens; use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, - Frame, + layout::Rect, + style::{Modifier, Style}, + text::Span, }; -use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext}; -use super::scrollbar::{render_scrollbar, should_show_scrollbar}; -use super::status::{state_icon, state_label, state_label_color}; -use super::text::{display_width, display_width_u16, truncate_end}; -use crate::app::state::{AgentPanelSort, Palette}; -use crate::app::{AppState, Mode}; +pub(crate) use self::tokens::{ + agent_rows as sidebar_agent_rows, space_rows as sidebar_space_rows, AgentTokenContext, + ResolvedToken, ResolvedTokenKind, SpaceTokenContext, +}; +use super::text::{display_width, truncate_end}; +use crate::app::state::Palette; +use crate::app::AppState; use crate::detect::AgentState; use crate::terminal::TerminalRuntimeRegistry; -const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; -const AGENT_PANEL_HEADER_ROWS: u16 = 3; - pub(crate) struct AgentPanelEntry { pub ws_idx: usize, pub tab_idx: usize, pub pane_id: crate::layout::PaneId, - pub primary_label: String, - pub primary_tab_label: Option, - pub pane_label: Option, - pub terminal_title: Option, - pub terminal_title_stripped: Option, - pub agent_label: Option, pub agent_kind_label: Option, - pub agent: Option, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option, - pub state_labels: std::collections::HashMap, pub tokens: std::collections::HashMap, } -fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { - if total_h == 0 { +fn sidebar_section_heights(total_height: u16, split_ratio: f32) -> (u16, u16) { + if total_height == 0 { return (0, 0); } - - if total_h < 6 { - let ws_h = total_h.div_ceil(2); - return (ws_h, total_h.saturating_sub(ws_h)); + if total_height < 6 { + let workspace_height = total_height.div_ceil(2); + return ( + workspace_height, + total_height.saturating_sub(workspace_height), + ); } - let ratio = split_ratio.clamp(0.1, 0.9); - let ws_h = ((total_h as f32) * ratio).round() as u16; - let ws_h = ws_h.clamp(3, total_h.saturating_sub(3)); - let detail_h = total_h.saturating_sub(ws_h); - (ws_h, detail_h) + let workspace_height = ((total_height as f32) * split_ratio.clamp(0.1, 0.9)).round() as u16; + let workspace_height = workspace_height.clamp(3, total_height.saturating_sub(3)); + ( + workspace_height, + total_height.saturating_sub(workspace_height), + ) } pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { + if content.is_empty() { return (Rect::default(), Rect::default()); } - let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio); - let ws_area = Rect::new(content.x, content.y, content.width, ws_h); - let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h); - (ws_area, detail_area) + let (workspace_height, detail_height) = sidebar_section_heights(content.height, split_ratio); + ( + Rect::new(content.x, content.y, content.width, workspace_height), + Rect::new( + content.x, + content.y + workspace_height, + content.width, + detail_height, + ), + ) } pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect { @@ -74,949 +71,46 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect return Rect::default(); } - let (ws_h, _) = sidebar_section_heights(content.height, split_ratio); - Rect::new(content.x, content.y + ws_h, content.width, 1) -} - -fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { - match sort { - AgentPanelSort::Spaces => "grouped", - AgentPanelSort::Priority => "priority", - } -} - -pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { - agent_panel_header_label_rect(area, agent_panel_sort_label(sort)) -} - -fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect { - if area.width == 0 || area.height < 2 { - return Rect::default(); - } - - let width = display_width_u16(label).min(area.width); - Rect::new( - area.x + area.width.saturating_sub(width), - area.y + 1, - width, - 1, - ) -} - -fn active_agent_view_label(app: &AppState) -> Option<&str> { - app.agent_view_override - .as_ref() - .map(|view| view.label.as_deref().unwrap_or("filtered")) -} - -pub(crate) fn agent_panel_entries(app: &AppState) -> Vec { - agent_panel_entries_with_runtimes(app, None) -} - -pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec { - collect_agent_panel_entries_with_runtimes(app, None) + let (workspace_height, _) = sidebar_section_heights(content.height, split_ratio); + Rect::new(content.x, content.y + workspace_height, content.width, 1) } pub(crate) fn agent_panel_entries_from( app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, + _terminal_runtimes: &TerminalRuntimeRegistry, ) -> Vec { - agent_panel_entries_with_runtimes(app, Some(terminal_runtimes)) -} - -fn agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, -) -> Vec { - let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes); + let mut entries = app + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + workspace + .pane_details(&app.terminals) + .into_iter() + .map(move |detail| AgentPanelEntry { + ws_idx, + tab_idx: detail.tab_idx, + pane_id: detail.pane_id, + agent_kind_label: detail.agent_kind_label, + state: detail.state, + seen: detail.seen, + last_agent_state_change_seq: detail.last_agent_state_change_seq, + tokens: detail.tokens, + }) + }) + .collect(); crate::app::agent_view::apply_agent_view(app, &mut entries); entries } -fn collect_agent_panel_entries_with_runtimes( - app: &AppState, - terminal_runtimes: Option<&TerminalRuntimeRegistry>, -) -> Vec { - let empty_runtimes; - let terminal_runtimes = match terminal_runtimes { - Some(terminal_runtimes) => terminal_runtimes, - None => { - empty_runtimes = TerminalRuntimeRegistry::new(); - &empty_runtimes - } - }; - - app.workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes); - ws.pane_details(&app.terminals) - .into_iter() - .map(move |detail| { - let show_tab = multi_tab - || ws - .tabs - .get(detail.tab_idx) - .is_some_and(|tab| !tab.is_auto_named()); - AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: workspace_label.clone(), - primary_tab_label: show_tab.then_some(detail.tab_label), - pane_label: detail.pane_label, - terminal_title: detail.terminal_title, - terminal_title_stripped: detail.terminal_title_stripped, - agent_label: Some(detail.agent_label), - agent_kind_label: detail.agent_kind_label, - agent: detail.agent, - state: detail.state, - seen: detail.seen, - last_agent_state_change_seq: detail.last_agent_state_change_seq, - state_labels: detail.state_labels, - tokens: detail.tokens, - } - }) - }) - .collect() -} - -pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Working, _) => "working", - (AgentState::Blocked, _) => "blocked", - (AgentState::Unknown, _) => "unknown", - } -} - -fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 { - let (state, seen) = ws.aggregate_state(&app.terminals); - let label = if indented { - grouped_child_display_label( - &ws.display_name_from_terminals(&app.terminals), - ws.branch().as_deref(), - ws.custom_name.is_some(), - ) - } else { - ws.display_name_from_terminals(&app.terminals) - }; - let token_values = ws.metadata_tokens.values(); - tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &label, - branch: ws.branch().as_deref(), - state_text: state_label(state, seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: indented, - }, - ) - .len() - .max(1) - .min(u16::MAX as usize) as u16 -} - -fn workspace_row_height_in_body( - app: &AppState, - workspace: &crate::workspace::Workspace, - indented: bool, - body_height: u16, -) -> u16 { - workspace_row_height(app, workspace, indented).min(body_height) -} - -fn workspace_entry_gap(app: &AppState, entries: &[WorkspaceListEntry], entry_idx: usize) -> u16 { - if entry_idx + 1 < entries.len() && !next_entry_is_indented_workspace(entries, entry_idx) { - app.sidebar_spaces.row_gap - } else { - 0 - } -} - -fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Working, _) => 2, - (AgentState::Idle, true) => 1, - (AgentState::Unknown, _) => 0, - } -} - -fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) { - app.workspaces - .iter() - .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key)) - .map(|ws| ws.aggregate_state(&app.terminals)) - .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen)) - .unwrap_or((AgentState::Unknown, true)) -} - -pub(crate) fn workspace_parent_group_state( - app: &AppState, - ws_idx: usize, -) -> Option<(String, bool)> { - let space = app.workspaces.get(ws_idx)?.worktree_space()?; - if space.is_linked_worktree { - return None; - } - let member_count = app - .workspaces - .iter() - .filter(|ws| { - ws.worktree_space() - .is_some_and(|member| member.key == space.key) - }) - .count(); - (member_count >= 2).then(|| { - ( - space.key.clone(), - app.collapsed_space_keys.contains(&space.key), - ) - }) -} - -pub(crate) fn grouped_child_display_label( - label: &str, - branch: Option<&str>, - has_custom_name: bool, -) -> String { - if has_custom_name { - return label.to_string(); - } - let Some(branch) = branch else { - return label.to_string(); - }; - branch - .strip_prefix("worktree/") - .unwrap_or(branch) - .to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum WorkspaceListEntry { - Workspace { ws_idx: usize, indented: bool }, -} - -pub(crate) fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool { - matches!( - entries.get(idx.saturating_add(1)), - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) -} - -pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(ws_area, false); - if body.height == 0 { - return requested; - } - - if workspace_list_entries(app).is_empty() { - 0 - } else { - requested.min(workspace_list_bottom_start(app, ws_area)) - } -} - -pub(crate) fn workspace_list_entries(app: &AppState) -> Vec { - workspace_list_entries_inner(app, false) -} - -/// Like [`workspace_list_entries`] but always expands worktree groups, ignoring -/// `collapsed_space_keys`. The mobile switcher has no collapse affordance and -/// always shows the full worktree tree. -pub(crate) fn workspace_list_entries_expanded(app: &AppState) -> Vec { - workspace_list_entries_inner(app, true) -} - -fn workspace_list_entries_inner(app: &AppState, force_expanded: bool) -> Vec { - let mut members_by_key = std::collections::HashMap::>::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - if let Some(space) = ws.worktree_space() { - members_by_key - .entry(space.key.clone()) - .or_default() - .push(ws_idx); - } - } - let grouped_keys = members_by_key - .iter() - .filter(|(_, members)| { - members.len() >= 2 - && members.iter().any(|idx| { - app.workspaces - .get(*idx) - .and_then(|ws| ws.worktree_space()) - .is_some_and(|space| !space.is_linked_worktree) - }) - }) - .map(|(key, _)| key.clone()) - .collect::>(); - - let visible_group_idx = if matches!(app.mode, Mode::Navigate) { - Some(app.selected) - } else { - app.active - }; - let active_group = visible_group_idx.and_then(|idx| { - app.workspaces - .get(idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - }); - - let mut emitted_groups = std::collections::HashSet::::new(); - let mut entries = Vec::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { - let Some(space) = ws - .worktree_space() - .filter(|space| grouped_keys.contains(&space.key)) - else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - - if !emitted_groups.insert(space.key.clone()) { - continue; - } - - let Some(members) = members_by_key.get(&space.key) else { - continue; - }; - let Some(parent_idx) = members.iter().copied().find(|idx| { - app.workspaces - .get(*idx) - .and_then(|member| member.worktree_space()) - .is_some_and(|member_space| !member_space.is_linked_worktree) - }) else { - entries.push(WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - }); - continue; - }; - let collapsed = !force_expanded && app.collapsed_space_keys.contains(&space.key); - entries.push(WorkspaceListEntry::Workspace { - ws_idx: parent_idx, - indented: false, - }); - - if collapsed { - if let Some(active_idx) = visible_group_idx - .filter(|idx| *idx != parent_idx) - .filter(|_| active_group.as_deref() == Some(space.key.as_str())) - { - entries.push(WorkspaceListEntry::Workspace { - ws_idx: active_idx, - indented: true, - }); - } - } else { - for member_idx in members { - if *member_idx == parent_idx { - continue; - } - entries.push(WorkspaceListEntry::Workspace { - ws_idx: *member_idx, - indented: true, - }); - } - } - } - entries -} - -pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect { - let (ws_area, _) = expanded_sidebar_sections(area, split_ratio); - ws_area -} - -pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS); - let footer_y = area.y + area.height.saturating_sub(1); - let body_height = footer_y.saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = workspace_list_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - let (row_height, gap) = match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - ( - workspace_row_height_in_body(app, ws, *indented, body.height), - workspace_entry_gap(app, &entries, entry_idx), - ) - } - }; - if used_rows.saturating_add(row_height) > body.height { - break; - } - used_rows = used_rows.saturating_add(row_height); - visible += 1; - used_rows = used_rows.saturating_add(gap).min(body.height); - } - visible -} - -fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize { - let body = workspace_list_body_rect(area, false); - let entries = workspace_list_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (entry_idx, entry) in entries.iter().enumerate().rev() { - let WorkspaceListEntry::Workspace { ws_idx, indented } = entry; - let Some(workspace) = app.workspaces.get(*ws_idx) else { - continue; - }; - let gap = workspace_entry_gap(app, &entries, entry_idx); - let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) - .saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = entry_idx; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn workspace_list_scroll_metrics( - app: &AppState, - area: Rect, -) -> crate::pane::ScrollMetrics { - let max_scroll = workspace_list_bottom_start(app, area); - let scroll = app.workspace_scroll.min(max_scroll); - let viewport_rows = workspace_list_visible_count(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option { - let metrics = workspace_list_scroll_metrics(app, area); - let body = workspace_list_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { - if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS { - return Rect::default(); - } - - let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS); - let body_height = (area.y + area.height).saturating_sub(body_y); - let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); - Rect::new(area.x, body_y, body_width, body_height) -} - -fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec> { - let label = entry - .state_labels - .get(agent_panel_status_key(entry.state, entry.seen)) - .map(String::as_str) - .unwrap_or_else(|| state_label(entry.state, entry.seen)); - tokens::agent_rows(&app.sidebar_agents, entry, label) -} - -pub(crate) fn agent_entry_height_in_body( - app: &AppState, - entry: &AgentPanelEntry, - body_height: u16, -) -> u16 { - (resolved_agent_rows(app, entry) - .len() - .max(1) - .min(u16::MAX as usize) as u16) - .min(body_height) -} - -pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 { - if entry_idx + 1 < entry_count { - app.sidebar_agents.row_gap - } else { - 0 - } -} - -fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize { - let body = agent_panel_body_rect(area, false); - if body.width == 0 || body.height == 0 { - return 0; - } - - let mut used_rows = 0u16; - let mut visible = 0usize; - let entries = agent_panel_entries(app); - for (index, entry) in entries.iter().enumerate().skip(scroll) { - let height = agent_entry_height_in_body(app, entry, body.height); - if used_rows.saturating_add(height) > body.height { - break; - } - used_rows = used_rows.saturating_add(height); - visible += 1; - used_rows = used_rows - .saturating_add(agent_entry_gap(app, index, entries.len())) - .min(body.height); - } - visible -} - -fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize { - let body = agent_panel_body_rect(area, false); - let entries = agent_panel_entries(app); - let mut used_rows = 0u16; - let mut start = entries.len(); - for (index, entry) in entries.iter().enumerate().rev() { - let gap = agent_entry_gap(app, index, entries.len()); - let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap); - if used_rows.saturating_add(needed) > body.height { - break; - } - used_rows = used_rows.saturating_add(needed); - start = index; - } - start.min(entries.len().saturating_sub(1)) -} - -pub(crate) fn agent_panel_scroll_for_target( - app: &AppState, - area: Rect, - current_scroll: usize, - target: usize, -) -> usize { - let max_scroll = agent_panel_bottom_start(app, area); - if target < current_scroll { - return target.min(max_scroll); - } - let mut scroll = current_scroll.min(max_scroll); - while scroll < target { - let visible = agent_panel_visible_count_from(app, area, scroll); - if visible > 0 && target < scroll.saturating_add(visible) { - break; - } - scroll += 1; - } - scroll.min(max_scroll) -} - -pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { - let max_scroll = agent_panel_bottom_start(app, area); - let scroll = app.agent_panel_scroll.min(max_scroll); - let viewport_rows = agent_panel_visible_count_from(app, area, scroll); - - crate::pane::ScrollMetrics { - offset_from_bottom: max_scroll.saturating_sub(scroll), - max_offset_from_bottom: max_scroll, - viewport_rows, - } -} - -pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option { - let metrics = agent_panel_scroll_metrics(app, area); - let body = agent_panel_body_rect(area, true); - (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( - area.x + area.width.saturating_sub(1), - body.y, - 1, - body.height, - )) -} - -pub(crate) fn compute_workspace_list_areas( - app: &AppState, - area: Rect, -) -> (Vec, Vec<()>) { - let ws_area = workspace_list_rect(area, app.sidebar_section_split); - if ws_area == Rect::default() { - return (Vec::new(), Vec::new()); - } - - let metrics = workspace_list_scroll_metrics(app, ws_area); - let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics)); - if body.width == 0 || body.height == 0 { - return (Vec::new(), Vec::new()); - } - - let scroll = app.workspace_scroll; - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let mut cards = Vec::new(); - let headers = Vec::new(); - - let entries = workspace_list_entries(app); - for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - match entry { - WorkspaceListEntry::Workspace { ws_idx, indented } => { - let Some(ws) = app.workspaces.get(*ws_idx) else { - continue; - }; - let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); - let gap = workspace_entry_gap(app, &entries, entry_idx); - if row_y.saturating_add(row_height) > body_bottom { - break; - } - cards.push(crate::app::state::WorkspaceCardArea { - ws_idx: *ws_idx, - rect: Rect::new(body.x, row_y, body.width, row_height), - indented: *indented, - }); - row_y = row_y - .saturating_add(row_height) - .saturating_add(gap) - .min(body_bottom); - } - } - } - - (cards, headers) -} - -pub(crate) fn compute_workspace_card_areas( - app: &AppState, - area: Rect, -) -> Vec { - compute_workspace_list_areas(app, area).0 -} - -pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect { - if card.rect.width == 0 || card.rect.height == 0 { - return Rect::default(); - } - - Rect::new( - card.rect.x + card.rect.width.saturating_sub(1), - card.rect.y, - 1, - 1, - ) -} - -/// Auto-scale sidebar width based on workspace identity + agent summary. -pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option, Rect) { - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { - return (Rect::default(), None, Rect::default()); - } - - if content.height < 7 { - return (content, None, Rect::default()); - } - - let total_h = content.height as usize; - let ws_h = total_h.div_ceil(2); - let detail_h = total_h.saturating_sub(ws_h + 1); - if ws_h == 0 || detail_h == 0 { - return (content, None, Rect::default()); - } - - let divider_y = content.y + ws_h as u16; - let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); - let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16); - (ws_area, Some(divider_y), detail_area) -} - -fn workspace_selection_background(p: &Palette, is_active: bool) -> Color { - if is_active && p.selection_bg == Color::Reset { - p.active_row_bg - } else { - p.selection_bg - } -} - -/// Collapsed sidebar: workspace glance on top, compact agent list below. -pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - - let is_navigating = matches!(app.mode, Mode::Navigate); - - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area); - if ws_area == Rect::default() { - render_sidebar_toggle(app, frame, area, true, p); - return; - } - - for (visible_idx, ws) in app.workspaces.iter().enumerate() { - let y = ws_area.y + visible_idx as u16; - if y >= ws_area.y + ws_area.height { - break; - } - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); - let is_selected = visible_idx == app.selected && is_navigating; - let is_active = Some(visible_idx) == app.active; - let selection_bg = workspace_selection_background(p, is_active); - let row_style = if is_selected { - Style::default().bg(selection_bg) - } else if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let num_style = if is_selected { - Style::default().fg(p.overlay1).bg(selection_bg) - } else if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - - if is_selected || is_active { - let buf = frame.buffer_mut(); - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, y)].set_style(row_style); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{:<2}", visible_idx + 1), num_style), - Span::styled(icon, icon_style), - ])), - Rect::new(ws_area.x, y, ws_area.width, 1), - ); - } - - if let Some(divider_y) = divider_y { - let buf = frame.buffer_mut(); - let divider_color = if app.agent_view_override.is_some() { - p.accent - } else { - p.surface_dim - }; - for x in ws_area.x..ws_area.x + ws_area.width { - buf[(x, divider_y)].set_symbol("─"); - buf[(x, divider_y)].set_style(Style::default().fg(divider_color)); - } - } - - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area != Rect::default() { - for (detail_idx, detail) in agent_panel_entries(app).iter().enumerate() { - let y = detail_content_area.y + detail_idx as u16; - if y >= detail_content_area.y + detail_content_area.height { - break; - } - let position = detail_idx + 1; - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let position_style = if is_active { - Style::default().fg(p.text).bg(p.active_row_bg) - } else { - Style::default().fg(p.overlay0) - }; - let (icon, icon_style) = - state_icon(detail.state, detail.seen, app.status_indicators, p); - - if is_active { - let buf = frame.buffer_mut(); - for x in detail_content_area.x..detail_content_area.x + detail_content_area.width { - buf[(x, y)].set_style(Style::default().bg(p.active_row_bg)); - } - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!("{position:<2}"), position_style), - Span::styled(icon, icon_style), - ])), - Rect::new(detail_content_area.x, y, detail_content_area.width, 1), - ); - } - } - - render_sidebar_toggle(app, frame, area, true, p); -} - -pub(crate) fn workspace_drop_slots( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, -) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> { - if area.height == 0 || cards.is_empty() { - return Vec::new(); - } - let list_bottom = area.y + area.height.saturating_sub(1); - let entries = workspace_list_entries(app); - let entry_position = |ws_idx| { - entries.iter().position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { - ws_idx: entry_ws_idx, - .. - } if *entry_ws_idx == ws_idx - ) - }) - }; - let block_root_at = |entry_idx: usize| { - entries[..=entry_idx] - .iter() - .rev() - .find_map(|entry| match entry { - WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(*ws_idx), - WorkspaceListEntry::Workspace { .. } => None, - }) - }; - - let mut slots = Vec::new(); - let mut previous_root = None; - for card in cards { - let Some(entry_idx) = entry_position(card.ws_idx) else { - continue; - }; - let Some(root_idx) = block_root_at(entry_idx) else { - continue; - }; - if previous_root == Some(root_idx) { - continue; - } - previous_root = Some(root_idx); - if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) { - slots.push(( - crate::app::state::WorkspaceDropTarget::Before(root_idx), - row, - )); - } - } - - let Some(last) = cards.last() else { - return slots; - }; - let Some(last_entry_idx) = entry_position(last.ws_idx) else { - return slots; - }; - let next_entry = entries.get(last_entry_idx.saturating_add(1)); - if matches!( - next_entry, - Some(WorkspaceListEntry::Workspace { indented: true, .. }) - ) { - return slots; - } - let target = match next_entry { - Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => { - crate::app::state::WorkspaceDropTarget::Before(*ws_idx) - } - None => crate::app::state::WorkspaceDropTarget::End, - }; - let row = last.rect.y.saturating_add(last.rect.height); - if row < list_bottom - && slots - .last() - .is_none_or(|(last_target, _)| *last_target != target) - { - slots.push((target, row)); - } - slots -} - -pub(crate) fn workspace_drop_indicator_row( - app: &AppState, - cards: &[crate::app::state::WorkspaceCardArea], - area: Rect, - target: crate::app::state::WorkspaceDropTarget, -) -> Option { - workspace_drop_slots(app, cards, area) - .into_iter() - .find_map(|(candidate, row)| (candidate == target).then_some(row)) -} - -pub(super) fn render_sidebar( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - frame - .buffer_mut() - .set_style(area, Style::default().bg(p.sidebar_bg)); - let is_navigating = matches!(app.mode, Mode::Navigate); - let sep_style = if is_navigating { - Style::default().fg(p.accent) - } else { - Style::default().fg(p.surface_dim) - }; - - let sep_x = area.x + area.width.saturating_sub(1); - let buf = frame.buffer_mut(); - for y in area.y..area.y + area.height { - buf[(sep_x, y)].set_symbol("│"); - buf[(sep_x, y)].set_style(sep_style); - } - - let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - - render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating); - render_agent_detail(app, terminal_runtimes, frame, detail_area); - render_sidebar_toggle(app, frame, area, false, p); -} - -fn resolved_token_spans( +pub(crate) fn resolved_token_spans( resolved: &[ResolvedToken], state_icon: (&str, Style), state_text_style: Style, workspace_style: Style, secondary_style: Style, custom_style: Style, - p: &Palette, + palette: &Palette, max_width: usize, ) -> Vec> { let fixed_widths = resolved @@ -1116,6 +210,7 @@ fn resolved_token_spans( break; } } + let mut spans = Vec::new(); for (position, index) in visible_indices.iter().copied().enumerate() { let token = &resolved[index]; @@ -1123,42 +218,36 @@ fn resolved_token_spans( let previous = &resolved[visible_indices[position - 1]]; spans.push(Span::styled( tokens::separator(previous, token), - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), )); } match &token.kind { - ResolvedTokenKind::StateIcon => { - spans.push(Span::styled( - state_icon.0.to_string(), - apply_token_style(state_icon.1, token.style), - )); - } - ResolvedTokenKind::StateText(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(state_text_style, token.style), - )); - } - ResolvedTokenKind::Workspace(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(workspace_style, token.style), - )); - } + ResolvedTokenKind::StateIcon => spans.push(Span::styled( + state_icon.0.to_string(), + apply_token_style(state_icon.1, token.style), + )), + ResolvedTokenKind::StateText(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(state_text_style, token.style), + )), + ResolvedTokenKind::Workspace(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(workspace_style, token.style), + )), ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) | ResolvedTokenKind::Agent(text) - | ResolvedTokenKind::Branch(text) => { - spans.push(Span::styled( - truncate_end(text, budgets[index]), - apply_token_style(secondary_style, token.style), - )); - } + | ResolvedTokenKind::Branch(text) => spans.push(Span::styled( + truncate_end(text, budgets[index]), + apply_token_style(secondary_style, token.style), + )), ResolvedTokenKind::GitStatus { ahead, behind } => { if *ahead > 0 { spans.push(Span::styled( format!("↑{ahead}"), - apply_token_style(Style::default().fg(p.green), token.style), + apply_token_style(Style::default().fg(palette.green), token.style), )); } if *ahead > 0 && *behind > 0 { @@ -1170,7 +259,7 @@ fn resolved_token_spans( if *behind > 0 { spans.push(Span::styled( format!("↓{behind}"), - apply_token_style(Style::default().fg(p.red), token.style), + apply_token_style(Style::default().fg(palette.red), token.style), )); } } @@ -1186,8 +275,8 @@ fn resolved_token_spans( } fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style { - if let Some(fg) = patch.fg { - style = style.fg(fg.ratatui()); + if let Some(foreground) = patch.fg { + style = style.fg(foreground.ratatui()); } if let Some(bold) = patch.bold { style = if bold { @@ -1205,1974 +294,3 @@ fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) } style } - -fn render_workspace_list( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, - is_navigating: bool, -) { - let p = &app.palette; - let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => { - Some(*source_ws_idx) - } - _ => None, - }; - let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) { - Some(crate::app::state::DragTarget::WorkspaceReorder { - drop_target: Some(drop_target), - .. - }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target), - _ => None, - }; - - let list_bottom = area.y + area.height.saturating_sub(1); - if area.height > 0 { - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " spaces", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y, area.width, 1), - ); - } - - let metrics = workspace_list_scroll_metrics(app, area); - let scrollbar_rect = workspace_list_scrollbar_rect(app, area); - let cards = &app.view.workspace_card_areas; - let entries = workspace_list_entries(app); - - for card in cards { - let i = card.ws_idx; - let ws = &app.workspaces[i]; - let row_y = card.rect.y; - let row_height = card.rect.height; - let selected = i == app.selected && is_navigating; - let is_active = Some(i) == app.active; - let is_dragged = dragged_ws_idx == Some(i); - let highlighted = selected || is_active || is_dragged; - let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - - if highlighted { - let bg = if selected { - workspace_selection_background(p, is_active) - } else if is_dragged { - p.surface1 - } else { - p.active_row_bg - }; - let buf = frame.buffer_mut(); - for y in row_y..row_y + row_height { - if y >= list_bottom { - break; - } - for x in card.rect.x..card.rect.x + card.rect.width { - buf[(x, y)].set_style(Style::default().bg(bg)); - } - } - } - - let name_style = if selected || is_active || is_dragged { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - - let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let display_label = if card.indented { - grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some()) - } else { - label - }; - let parent_group = (!card.indented) - .then(|| workspace_parent_group_state(app, i)) - .flatten(); - let is_last_child = card.indented - && entries - .iter() - .position(|entry| { - matches!( - entry, - WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i - ) - }) - .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx)); - let (display_state, display_seen) = parent_group - .as_ref() - .filter(|(_, collapsed)| *collapsed) - .map(|(key, _)| space_aggregate_state(app, key)) - .unwrap_or((agg_state, agg_seen)); - let state_icon = state_icon(display_state, display_seen, app.status_indicators, p); - let state_text_style = Style::default() - .fg(state_label_color(display_state, display_seen, p)) - .add_modifier(Modifier::DIM); - let branch_style = Style::default().fg(if selected || is_active { - p.mauve - } else { - p.overlay0 - }); - let token_values = ws.metadata_tokens.values(); - let rows = tokens::space_rows( - &app.sidebar_spaces, - SpaceTokenContext { - workspace: &display_label, - branch: ws.branch().as_deref(), - state_text: state_label(display_state, display_seen), - ahead_behind: ws.git_ahead_behind(), - tokens: &token_values, - suppress_git_details: card.indented, - }, - ); - - for (row_index, resolved) in rows.iter().enumerate() { - if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom { - break; - } - let mut spans = Vec::new(); - let prefix_width = if card.indented { - spans.push(Span::raw(" ")); - if row_index == 0 { - spans.push(Span::styled( - if is_last_child { "└─ " } else { "├─ " }, - Style::default().fg(p.overlay0), - )); - 6 - } else if is_last_child { - spans.push(Span::raw(" ")); - 8 - } else { - spans.push(Span::styled("│", Style::default().fg(p.overlay0))); - spans.push(Span::raw(" ")); - 8 - } - } else if row_index == 0 { - spans.push(Span::raw(" ")); - 1 - } else { - spans.push(Span::raw(" ")); - 3 - }; - let trailing_width = if row_index == 0 && parent_group.is_some() { - 2 - } else { - 0 - }; - spans.extend(resolved_token_spans( - resolved, - state_icon, - state_text_style, - name_style, - branch_style, - branch_style, - p, - card.rect - .width - .saturating_sub(prefix_width + trailing_width) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1), - ); - } - - if let Some((_, collapsed)) = parent_group { - frame.render_widget( - Paragraph::new(Span::styled( - if collapsed { "▸" } else { "▾" }, - Style::default().fg(p.accent), - )), - workspace_group_chevron_rect(card), - ); - } - } - - if let Some(y) = insertion_row.filter(|y| *y < list_bottom) { - let indicator_right = scrollbar_rect - .map(|rect| rect.x) - .unwrap_or(area.x + area.width); - let buf = frame.buffer_mut(); - for x in area.x..indicator_right { - buf[(x, y)].set_symbol("─"); - buf[(x, y)].set_style(Style::default().fg(p.accent)); - } - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } - - if app.mouse_capture && list_bottom > area.y { - let new_rect = app.sidebar_new_button_rect(); - frame.render_widget( - Paragraph::new(Span::styled(" new", Style::default().fg(p.overlay0))), - new_rect, - ); - - let menu_rect = app.global_launcher_rect(); - let menu_line = if app.global_menu_attention_badge_visible() { - Line::from(vec![ - Span::styled( - "● ", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled("menu", Style::default().fg(p.overlay0)), - ]) - } else { - Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))]) - }; - frame.render_widget( - Paragraph::new(menu_line).alignment(Alignment::Right), - menu_rect, - ); - } -} - -fn render_agent_detail( - app: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - frame: &mut Frame, - area: Rect, -) { - let p = &app.palette; - - if area.height < 3 { - return; - } - - let sep_line = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), - Rect::new(area.x, area.y, area.width, 1), - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - " agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), - )])), - Rect::new(area.x, area.y + 1, area.width, 1), - ); - let control_label = active_agent_view_label(app) - .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort)); - let toggle_rect = agent_panel_header_label_rect(area, control_label); - if toggle_rect != Rect::default() { - let color = if app.agent_view_override.is_some() { - p.accent - } else { - p.overlay0 - }; - frame.render_widget( - Paragraph::new(Span::styled( - control_label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )) - .alignment(Alignment::Right), - toggle_rect, - ); - } - - let details = agent_panel_entries_from(app, terminal_runtimes); - let metrics = agent_panel_scroll_metrics(app, area); - let scrollbar_rect = agent_panel_scrollbar_rect(app, area); - let body = agent_panel_body_rect(area, should_show_scrollbar(metrics)); - if body == Rect::default() { - return; - } - if details.is_empty() && app.agent_view_override.is_some() { - frame.render_widget( - Paragraph::new(" no matching agents") - .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)), - Rect::new(body.x, body.y, body.width, 1), - ); - return; - } - - let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom); - let mut row_y = body.y; - let body_bottom = body.y + body.height; - for (index, detail) in details.iter().enumerate().skip(scroll) { - let label_color = state_label_color(detail.state, detail.seen, p); - let rows = resolved_agent_rows(app, detail); - let height = (rows.len().max(1) as u16).min(body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - - let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - let row_style = if is_active { - Style::default().bg(p.active_row_bg) - } else { - Style::default() - }; - let name_style = if is_active { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD) - }; - let status_style = if is_active { - Style::default().fg(label_color) - } else { - Style::default().fg(label_color).add_modifier(Modifier::DIM) - }; - let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - let state_icon = state_icon(detail.state, detail.seen, app.status_indicators, p); - - for (row_index, resolved) in rows.iter().take(height as usize).enumerate() { - let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })]; - spans.extend(resolved_token_spans( - resolved, - state_icon, - status_style, - name_style, - agent_style, - agent_style, - p, - body.width - .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize, - )); - frame.render_widget( - Paragraph::new(Line::from(spans)).style(row_style), - Rect::new(body.x, row_y + row_index as u16, body.width, 1), - ); - } - row_y = row_y - .saturating_add(height) - .saturating_add(agent_entry_gap(app, index, details.len())) - .min(body_bottom); - } - - if let Some(track) = scrollbar_rect { - render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); - } -} - -pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect { - let bottom_y = area.y + area.height.saturating_sub(1); - let content_w = area.width.saturating_sub(1); - if content_w == 0 || area.height == 0 { - return Rect::default(); - } - let x = area.x + content_w / 2; - Rect::new(x, bottom_y, 1, 1) -} - -pub(crate) fn expanded_sidebar_toggle_rect(area: Rect) -> Rect { - if area.width <= 1 || area.height == 0 { - return Rect::default(); - } - Rect::new( - area.x + area.width.saturating_sub(2), - area.y + area.height.saturating_sub(1), - 1, - 1, - ) -} - -fn render_sidebar_toggle( - app: &AppState, - frame: &mut Frame, - area: Rect, - collapsed: bool, - p: &Palette, -) { - let toggle_area = if collapsed { - collapsed_sidebar_toggle_rect(area) - } else { - expanded_sidebar_toggle_rect(area) - }; - if toggle_area == Rect::default() { - return; - } - let icon = if collapsed { "»" } else { "«" }; - let icon_style = if collapsed && app.global_menu_attention_badge_visible() { - Style::default().fg(p.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay0) - }; - frame.render_widget(Paragraph::new(Span::styled(icon, icon_style)), toggle_area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{detect::Agent, layout::PaneId, workspace::Workspace}; - use ratatui::{backend::TestBackend, layout::Direction, Terminal}; - - fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String { - (0..width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 { - (0..width) - .find(|x| buffer[(*x, row)].symbol() == symbol) - .unwrap_or_else(|| { - panic!( - "missing symbol {symbol:?} in row {}", - row_text(buffer, row, width) - ) - }) - } - - #[test] - fn expanded_and_collapsed_sidebars_use_custom_background() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces.clear(); - app.active = None; - app.palette.sidebar_bg = ratatui::style::Color::Rgb(12, 34, 56); - let area = Rect::new(0, 0, 26, 20); - - let mut expanded = Terminal::new(TestBackend::new(26, 20)).unwrap(); - expanded - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert!(expanded - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - - let mut collapsed = Terminal::new(TestBackend::new(26, 20)).unwrap(); - collapsed - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert!(collapsed - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg == app.palette.sidebar_bg)); - } - - #[test] - fn default_agent_rows_remove_redundant_state_text() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(Agent::Pi); - terminal_state.state = AgentState::Working; - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - - let first = row_text(buffer, body.y, 25); - let second = row_text(buffer, body.y + 1, 25); - assert!(first.contains("one")); - assert_eq!(second, " pi"); - assert!(!first.contains("working")); - assert!(!second.contains("working")); - - let workspace_x = find_symbol_x(buffer, body.y, body.width, "o"); - let workspace_style = buffer[(workspace_x, body.y)].style(); - assert_eq!(workspace_style.fg, Some(app.palette.text)); - assert!(workspace_style.add_modifier.contains(Modifier::BOLD)); - assert!(!workspace_style.add_modifier.contains(Modifier::DIM)); - assert_eq!(workspace_style.bg, Some(app.palette.active_row_bg)); - - let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p"); - let agent_style = buffer[(agent_x, body.y + 1)].style(); - assert_eq!(agent_style.fg, Some(app.palette.overlay0)); - assert!(agent_style.add_modifier.contains(Modifier::DIM)); - assert!(!agent_style.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent_style.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_false_removes_default_workspace_bold_and_agent_dim() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.agents] -rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_agents = config.ui.sidebar.agents; - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - app.active = Some(0); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 26, 20); - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let buffer = terminal.backend().buffer(); - let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style(); - let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style(); - - assert_eq!(workspace.fg, Some(app.palette.text)); - assert!(!workspace.add_modifier.contains(Modifier::BOLD)); - assert_eq!(agent.fg, Some(app.palette.overlay0)); - assert!(!agent.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn default_space_workspace_style_tracks_active_state() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.mode = Mode::Terminal; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let first_row = app.view.workspace_card_areas[0].rect.y; - let second_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style(); - assert_eq!(active.fg, Some(app.palette.text)); - assert!(active.add_modifier.contains(Modifier::BOLD)); - assert!(!active.add_modifier.contains(Modifier::DIM)); - assert_eq!(active.bg, Some(app.palette.active_row_bg)); - - let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style(); - assert_eq!(inactive.fg, Some(app.palette.subtext0)); - assert!(!inactive - .add_modifier - .intersects(Modifier::BOLD | Modifier::DIM)); - assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset)); - } - - #[test] - fn navigate_selection_keeps_its_existing_background_beside_active_workspace() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 1; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let selected_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!( - buffer[(0, active_row)].bg, - app.palette.active_row_bg, - "active workspace should keep its dedicated background" - ); - assert_eq!( - buffer[(0, selected_row)].bg, - app.palette.selection_bg, - "navigate selection should use its dedicated cursor background" - ); - } - - #[test] - fn selected_active_workspace_resolves_expanded_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let active_row = app.view.workspace_card_areas[0].rect.y; - let inactive_row = app.view.workspace_card_areas[1].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(0, inactive_row)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(0, active_row)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn selected_active_workspace_resolves_collapsed_background() { - let mut app = crate::app::state::AppState::test_new(); - app.palette = crate::app::state::Palette::terminal(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Navigate; - let area = Rect::new(0, 0, 5, 8); - let mut terminal = Terminal::new(TestBackend::new(5, 8)).unwrap(); - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - - app.selected = 1; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.active_row_bg - ); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y + 1)].bg, - app.palette.selection_bg - ); - - app.palette = crate::app::state::Palette::catppuccin(); - app.selected = 0; - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg, - app.palette.selection_bg - ); - } - - #[test] - fn space_occurrence_style_applies_without_styling_separator() { - let config: crate::config::Config = toml::from_str( - r##" -[ui.sidebar.spaces] -rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]] -"##, - ) - .unwrap(); - let mut app = crate::app::state::AppState::test_new(); - app.sidebar_spaces = config.ui.sidebar.spaces; - app.workspaces = vec![Workspace::test_new("one")]; - app.active = Some(0); - app.mode = Mode::Terminal; - app.workspaces[0].metadata_tokens.patch( - std::collections::HashMap::from([("hype".into(), Some("HI".into()))]), - None, - std::time::Instant::now(), - ); - - let area = Rect::new(0, 0, 26, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let row = app.view.workspace_card_areas[0].rect.y; - let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style(); - let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style(); - let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style(); - - for style in [h, i] { - assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef))); - assert!(style.add_modifier.contains(Modifier::BOLD)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert_eq!(style.bg, Some(app.palette.active_row_bg)); - } - assert_eq!(separator.fg, Some(app.palette.overlay0)); - assert!(separator.add_modifier.contains(Modifier::DIM)); - assert!(!separator.add_modifier.contains(Modifier::BOLD)); - assert_eq!(separator.bg, Some(app.palette.active_row_bg)); - } - - #[test] - fn occurrence_foreground_flattens_composite_git_status_colors() { - let config: crate::config::Config = toml::from_str( - r##"[ui.sidebar.spaces] -rows = [[{ token = "git_status", fg = "#123456" }]] -"##, - ) - .unwrap(); - let spans = resolved_token_spans( - &[ResolvedToken { - kind: ResolvedTokenKind::GitStatus { - ahead: 2, - behind: 1, - }, - style: config.ui.sidebar.spaces.rows[0][0].parts().1, - }], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &crate::app::state::AppState::test_new().palette, - 20, - ); - - assert_eq!( - spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(), - "↑2 ↓1" - ); - assert!(spans - .iter() - .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) })); - } - - #[test] - fn default_agent_row_gap_packs_rendering_and_scroll_geometry() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - assert_eq!(app.sidebar_agents.row_gap, 0); - - let area = Rect::new(0, 0, 20, 5); - let metrics = agent_panel_scroll_metrics(&app, area); - let body = agent_panel_body_rect(area, false); - let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); - terminal - .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - - assert_eq!(metrics.viewport_rows, 2); - assert_eq!(metrics.max_offset_from_bottom, 0); - assert_eq!(row_text(buffer, body.y, body.width), " pi"); - assert_eq!(row_text(buffer, body.y + 1, body.width), " claude"); - } - - #[test] - fn narrow_agent_rows_preserve_later_tab_tokens() { - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("very-long-workspace-name"); - let tab_idx = workspace.test_add_tab(Some("logs")); - let pane_id = workspace.tabs[tab_idx].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - - let area = Rect::new(0, 0, 18, 20); - let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap(); - terminal - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let first = row_text(buffer, body.y, 17); - - assert!(first.contains("logs"), "rendered row: {first:?}"); - assert!(first.contains('·'), "rendered row: {first:?}"); - } - - #[test] - fn stripped_terminal_title_renders_with_unicode_width_truncation() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into())); - app.sidebar_agents.rows = vec![vec![ - crate::config::AgentSidebarToken::TerminalTitleStripped, - ]]; - - let area = Rect::new(0, 0, 10, 12); - let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap(); - renderer - .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) - .unwrap(); - let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); - let body = agent_panel_body_rect(agent_area, false); - let rendered = row_text(renderer.backend().buffer(), body.y, 9); - - assert!(!rendered.contains('⠋')); - assert!(rendered.contains('修') && rendered.contains('复')); - - let spans = resolved_token_spans( - &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle( - "修复🙂标题很长".into(), - ))], - ("", Style::default()), - Style::default(), - Style::default(), - Style::default(), - Style::default(), - &app.palette, - 8, - ); - let text = spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - assert!(display_width(&text) <= 8, "resolved title: {text:?}"); - } - - #[test] - fn variable_agent_heights_pack_the_bottom_and_reveal_targets() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - ]; - app.ensure_test_terminals(); - for workspace in &app.workspaces { - let pane_id = workspace.tabs[0].root_pane; - let terminal_id = workspace.tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); - } - let first_pane = app.workspaces[0].tabs[0].root_pane; - let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal) - .unwrap() - .metadata_tokens - .patch( - std::collections::HashMap::from([ - ("a".into(), Some("a".into())), - ("b".into(), Some("b".into())), - ]), - None, - std::time::Instant::now(), - ); - app.sidebar_agents.rows = vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Custom("a".into())], - vec![crate::config::AgentSidebarToken::Custom("b".into())], - ]; - let area = Rect::new(0, 0, 20, 6); - - let metrics = agent_panel_scroll_metrics(&app, area); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1); - } - - #[test] - fn oversized_space_layout_is_clipped_to_the_section_body() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6]; - let area = Rect::new(0, 0, 20, 10); - let workspace_area = workspace_list_rect(area, app.sidebar_section_split); - let body = workspace_list_body_rect(workspace_area, false); - - let metrics = workspace_list_scroll_metrics(&app, workspace_area); - let (cards, _) = compute_workspace_list_areas(&app, area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 0); - assert_eq!(cards[0].rect.height, body.height); - } - - #[test] - fn oversized_agent_override_is_clipped_to_the_panel_body() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("one"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - app.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![vec![crate::config::AgentSidebarToken::Agent]; 6], - ); - let panel = Rect::new(0, 0, 20, 5); - - let metrics = agent_panel_scroll_metrics(&app, panel); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 0); - let entry = agent_panel_entries(&app).pop().unwrap(); - assert_eq!( - agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height), - agent_panel_body_rect(panel, false).height - ); - } - - #[test] - fn render_sidebar_toggle_draws_expanded_collapse_icon() { - let app = crate::app::state::AppState::test_new(); - let area = Rect::new(0, 0, 26, 20); - let mut terminal = - Terminal::new(TestBackend::new(26, 20)).expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_toggle(&app, frame, area, false, &app.palette)) - .expect("sidebar toggle should render"); - - let toggle = expanded_sidebar_toggle_rect(area); - assert_eq!( - terminal.backend().buffer()[(toggle.x, toggle.y)].symbol(), - "«" - ); - } - - #[test] - fn expanded_sidebar_toggle_sits_inside_sidebar_content() { - let area = Rect::new(0, 0, 26, 20); - let toggle = expanded_sidebar_toggle_rect(area); - - assert_eq!(toggle.x, area.x + area.width - 2); - assert_eq!(toggle.y, area.y + area.height - 1); - } - - #[test] - fn agent_panel_tab_label_visibility_tracks_tab_identity() { - let mut app = crate::app::state::AppState::test_new(); - let single_auto = Workspace::test_new("auto"); - let mut single_custom = Workspace::test_new("custom"); - single_custom.tabs[0].set_custom_name("focus".into()); - let mut multi = Workspace::test_new("multi"); - multi.test_add_tab(Some("logs")); - - app.workspaces = vec![single_auto, single_custom, multi]; - app.ensure_test_terminals(); - for (ws_idx, tab_idx, agent) in [ - (0, 0, Agent::Pi), - (1, 0, Agent::Claude), - (2, 0, Agent::Codex), - (2, 1, Agent::Pi), - ] { - let pane_id = app.workspaces[ws_idx].tabs[tab_idx].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); - } - - let entries = agent_panel_entries(&app); - let labels: Vec<_> = entries - .iter() - .map(|entry| { - ( - entry.primary_label.as_str(), - entry.primary_tab_label.as_deref(), - ) - }) - .collect(); - - assert_eq!( - labels, - [ - ("auto", None), - ("custom", Some("focus")), - ("multi", Some("1")), - ("multi", Some("logs")), - ] - ); - } - - #[test] - fn priority_agent_panel_sort_uses_attention_then_space_order() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("one"), - Workspace::test_new("two"), - Workspace::test_new("three"), - Workspace::test_new("four"), - ]; - app.ensure_test_terminals(); - app.active = Some(0); - app.selected = 0; - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, AgentState::Working); - set_state(&mut app, 1, AgentState::Idle); - set_state(&mut app, 2, AgentState::Working); - set_state(&mut app, 3, AgentState::Blocked); - - let done_pane = app.workspaces[1].tabs[0].root_pane; - app.workspaces[1].tabs[0] - .panes - .get_mut(&done_pane) - .unwrap() - .seen = false; - - let labels: Vec = agent_panel_entries(&app) - .into_iter() - .map(|entry| entry.primary_label) - .collect(); - - assert_eq!(labels, ["four", "two", "one", "three"]); - } - - #[test] - fn collapsed_sidebar_numbers_grouped_agents_by_list_position() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 12); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - } - - /// Two agent panes in one workspace plus a second workspace, so the - /// assertions can tell pane-level highlighting apart from workspace-level. - fn collapsed_agent_app() -> (crate::app::state::AppState, PaneId, PaneId) { - let mut app = crate::app::state::AppState::test_new(); - let mut first = Workspace::test_new("one"); - let second_pane = first.test_split(Direction::Horizontal); - let first_pane = first.tabs[0].root_pane; - app.workspaces = vec![first, Workspace::test_new("two")]; - app.ensure_test_terminals(); - - let terminal_ids: Vec<_> = app - .workspaces - .iter() - .flat_map(|ws| ws.tabs.iter()) - .flat_map(|tab| tab.panes.values()) - .map(|pane| pane.attached_terminal_id.clone()) - .collect(); - for terminal_id in terminal_ids { - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - (app, first_pane, second_pane) - } - - fn collapsed_agent_row_styles( - app: &crate::app::state::AppState, - area: Rect, - detail_area: Rect, - rows: u16, - ) -> Vec> { - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - terminal - .draw(|frame| render_sidebar_collapsed(app, frame, area)) - .expect("collapsed sidebar should render"); - let buffer = terminal.backend().buffer(); - (0..rows) - .map(|row| { - (detail_area.x..detail_area.x + detail_area.width) - .map(|x| buffer[(x, detail_area.y + row)].style()) - .collect() - }) - .collect() - } - - #[test] - fn collapsed_sidebar_highlights_only_the_focused_agent_pane() { - let (mut app, first_pane, second_pane) = collapsed_agent_app(); - app.active = Some(0); - app.workspaces[0].tabs[0].layout.focus_pane(second_pane); - assert!(app.is_active_pane(0, 0, second_pane)); - assert!(!app.is_active_pane(0, 0, first_pane)); - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - let highlighted: Vec<_> = rows - .iter() - .filter(|cells| { - cells - .iter() - .all(|style| style.bg == Some(app.palette.active_row_bg)) - }) - .collect(); - assert_eq!( - highlighted.len(), - 1, - "only the focused agent pane should be highlighted, across the whole row" - ); - assert_eq!(highlighted[0][0].fg, Some(app.palette.text)); - - let muted = rows - .iter() - .filter(|cells| cells[0].fg == Some(app.palette.overlay0)) - .count(); - assert_eq!( - muted, 2, - "the sibling pane in the active workspace and the other workspace stay muted" - ); - } - - #[test] - fn collapsed_sidebar_does_not_highlight_agents_without_active_workspace() { - let (mut app, _, _) = collapsed_agent_app(); - app.active = None; - - let area = Rect::new(0, 0, 4, 14); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let rows = collapsed_agent_row_styles(&app, area, detail_area, 3); - - for cells in rows { - assert_eq!(cells[0].fg, Some(app.palette.overlay0)); - for style in cells { - assert_ne!(style.bg, Some(app.palette.active_row_bg)); - } - } - } - - #[test] - fn collapsed_sidebar_keeps_workspace_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (workspace_area, _, _) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = workspace_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(workspace_area.x, workspace_area.y)].symbol(), "1"); - assert_eq!( - buffer[(workspace_area.x + 1, workspace_area.y)].symbol(), - " " - ); - assert_eq!( - buffer[(workspace_area.x + 2, workspace_area.y)].symbol(), - "·" - ); - assert_eq!(buffer[(workspace_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(workspace_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(workspace_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_keeps_status_visible_for_two_digit_positions() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = (1..=10) - .map(|idx| Workspace::test_new(&format!("workspace-{idx}"))) - .collect(); - app.ensure_test_terminals(); - - for ws_idx in 0..app.workspaces.len() { - let pane = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude); - } - - let area = Rect::new(0, 0, 4, 25); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let tenth_row = detail_area.y + 9; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, tenth_row)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x + 1, tenth_row)].symbol(), "0"); - assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "·"); - } - - #[test] - fn collapsed_sidebar_numbers_priority_agents_by_list_position() { - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let mut second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - let urgent_pane = second.test_split(ratatui::layout::Direction::Horizontal); - - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![first, second]; - app.ensure_test_terminals(); - app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - app.status_indicators = crate::config::StatusIndicatorStyle::Symbols; - - let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, pane_id, state| { - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Idle); - set_state(&mut app, 1, second_pane, AgentState::Working); - set_state(&mut app, 1, urgent_pane, AgentState::Blocked); - app.workspaces[0].tabs[0] - .panes - .get_mut(&first_pane) - .unwrap() - .seen = false; - - assert_eq!(app.workspaces[1].public_pane_number(urgent_pane), Some(2)); - assert_eq!(agent_panel_entries(&app)[0].pane_id, urgent_pane); - - let area = Rect::new(0, 0, 4, 16); - let (_, _, detail_area) = collapsed_sidebar_sections(area); - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) - .expect("test terminal should initialize"); - - terminal - .draw(|frame| render_sidebar_collapsed(&app, frame, area)) - .expect("collapsed sidebar should render"); - - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); - assert_eq!(buffer[(detail_area.x, detail_area.y + 2)].symbol(), "3"); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "×"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y)].style().fg, - Some(app.palette.red) - ); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y + 1)].symbol(), "✓"); - assert_eq!( - buffer[(detail_area.x + 2, detail_area.y + 1)].style().fg, - Some(app.palette.teal) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() { - let unique = format!( - "herdr-agent-panel-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut app = crate::app::state::AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let terminal_id = app.workspaces[0].tabs[0].panes[&pane] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.cwd = stale_cwd; - terminal.detected_agent = Some(Agent::Pi); - app.active = Some(0); - app.selected = 0; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - let entries = agent_panel_entries_from(&app, &runtime_registry); - let primary_label = entries[0].primary_label.clone(); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - assert_eq!(primary_label, "herdr"); - } - - #[test] - fn all_workspaces_agent_panel_entries_prefer_agent_names_for_agent_identity() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = Workspace::test_new("bridge"); - let first_pane = workspace.tabs[0].root_pane; - - app.workspaces = vec![workspace]; - app.ensure_test_terminals(); - let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - app.terminals - .get_mut(&first_terminal_id) - .unwrap() - .set_agent_name("planner".into()); - app.active = Some(0); - app.selected = 0; - - let entries = agent_panel_entries(&app); - assert_eq!(entries[0].primary_label, "bridge"); - assert_eq!(entries[0].agent_label.as_deref(), Some("planner")); - } - - #[test] - fn expanded_sidebar_sections_handle_tiny_heights() { - let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); - - assert_eq!(ws_area, Rect::new(0, 0, 19, 3)); - assert_eq!(detail_area, Rect::new(0, 3, 19, 2)); - } - - #[test] - fn sidebar_section_divider_is_hidden_for_tiny_heights() { - let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5); - - assert_eq!(divider, Rect::default()); - } - - #[test] - fn grouped_child_label_keeps_custom_workspace_name() { - assert_eq!( - grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true), - "renamed issue" - ); - } - - #[test] - fn grouped_child_label_uses_short_branch_for_auto_named_workspace() { - assert_eq!( - grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false), - "issue-137" - ); - } - - #[test] - fn workspace_list_truncates_cjk_branch_without_panic() { - let mut app = crate::app::state::AppState::test_new(); - let mut ws = Workspace::test_new("repo"); - ws.cached_git_branch = Some("feature/中文-分支-644".into()); - app.workspaces = vec![ws]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app.view.workspace_card_areas = vec![crate::app::state::WorkspaceCardArea { - ws_idx: 0, - rect: Rect::new(0, 1, 15, 2), - indented: false, - }]; - - let mut terminal = Terminal::new(TestBackend::new(15, 6)).expect("test terminal"); - let runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - terminal - .draw(|frame| { - render_workspace_list(&app, &runtimes, frame, Rect::new(0, 0, 15, 6), false) - }) - .expect("workspace list should render"); - } - - fn workspace_with_worktree_space( - name: &str, - key: Option<&str>, - checkout_key: &str, - ) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - if let Some(key) = key { - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - checkout_path: std::path::PathBuf::from(checkout_key), - is_linked_worktree: name != "main", - }); - } - ws - } - - fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::test_new(name); - ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata { - key: key.into(), - checkout_key: format!("/repo/{name}"), - repo_name: "herdr".into(), - repo_root: std::path::PathBuf::from(format!("/repo/{name}")), - is_linked_worktree: false, - }); - ws - } - - #[test] - fn desktop_worktree_tree_aligns_parents_and_marks_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![ - crate::config::SpaceSidebarToken::StateIcon, - crate::config::SpaceSidebarToken::Workspace, - ]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let cards = &app.view.workspace_card_areas; - let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m"); - let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n"); - assert_eq!(parent_name_x, plain_name_x); - assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├"); - assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└"); - assert_eq!( - buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(), - "▾" - ); - } - - #[test] - fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 10); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - assert_eq!(app.view.workspace_card_areas.len(), 2); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - let child = app.view.workspace_card_areas[1]; - assert_eq!( - terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(), - "├" - ); - } - - #[test] - fn parent_workspace_row_stays_clickable_when_grouped() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.sidebar_spaces.row_gap = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20)); - - assert!(headers.is_empty()); - assert_eq!(cards[0].ws_idx, 0); - assert!(!cards[0].indented); - assert_eq!(cards[1].ws_idx, 1); - assert!(cards[1].indented); - assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height); - } - - #[test] - fn space_row_gap_preserves_compact_worktree_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - Workspace::test_new("notes"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 2; - - let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert_eq!( - spacious[1].rect.y, - spacious[0].rect.y + spacious[0].rect.height - ); - assert_eq!( - spacious[2].rect.y, - spacious[1].rect.y + spacious[1].rect.height - ); - assert_eq!( - spacious[3].rect.y, - spacious[2].rect.y + spacious[2].rect.height + 2 - ); - let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(spacious_metrics.viewport_rows, 3); - assert_eq!(spacious_metrics.max_offset_from_bottom, 2); - - app.sidebar_spaces.row_gap = 0; - let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); - assert!(packed - .windows(2) - .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height)); - let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); - assert_eq!(packed_metrics.viewport_rows, 4); - assert_eq!(packed_metrics.max_offset_from_bottom, 0); - } - - #[test] - fn packed_workspace_drag_indicator_overlays_an_internal_boundary() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.sidebar_spaces.row_gap = 0; - let area = Rect::new(0, 0, 30, 20); - app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); - let list_area = workspace_list_rect(area, app.sidebar_section_split); - let indicator_row = workspace_drop_indicator_row( - &app, - &app.view.workspace_card_areas, - list_area, - crate::app::state::WorkspaceDropTarget::Before(2), - ) - .unwrap(); - assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y); - app.drag = Some(crate::app::state::DragState { - target: crate::app::state::DragTarget::WorkspaceReorder { - source_id: 0, - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)), - }, - }); - - let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); - terminal - .draw(|frame| { - render_workspace_list( - &app, - &TerminalRuntimeRegistry::new(), - frame, - list_area, - false, - ) - }) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(), - "─" - ); - } - - #[test] - fn linked_only_worktree_members_do_not_form_parentless_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"), - ]; - - let entries = workspace_list_entries(&app); - - assert_eq!( - entries, - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false - }, - ] - ); - } - - #[test] - fn compact_space_group_scroll_clamps_when_all_entries_fit() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"), - workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"), - ]; - let area = Rect::new(0, 0, 30, 20); - app.workspace_scroll = normalized_workspace_scroll(&app, area, 2); - - let (cards, headers) = compute_workspace_list_areas(&app, area); - - assert!(headers.is_empty()); - assert_eq!(app.workspace_scroll, 0); - assert_eq!(cards.len(), 3); - assert_eq!(cards[2].ws_idx, 2); - } - - #[test] - fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - for workspace in &mut app.workspaces { - workspace.cached_git_branch = Some("main".into()); - } - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - - let ws_area = Rect::new(0, 0, 30, 6); - let metrics = workspace_list_scroll_metrics(&app, ws_area); - - assert_eq!(metrics.viewport_rows, 1); - assert_eq!(metrics.max_offset_from_bottom, 1); - assert_eq!(metrics.offset_from_bottom, 1); - } - - #[test] - fn workspace_scroll_offset_applies_to_group_children() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - Workspace::test_new("notes"), - ]; - app.collapsed_space_keys.insert("repo-key".into()); - app.active = None; - app.mode = Mode::Terminal; - app.workspace_scroll = 1; - - let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12)); - - assert!(headers.is_empty()); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 2); - } - - #[test] - fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_group_non_contiguous_explicit_members() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("normal", "other-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_group_normal_git_workspaces() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_git_space("two", "repo-key"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_git_space("scratch", "repo-key"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 2, - indented: true, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_git_space("one", "repo-key"), - workspace_with_worktree_space("notes", None, "/notes"), - ]; - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: false, - }, - ] - ); - } - - #[test] - fn collapsed_group_hides_inactive_children_but_keeps_active_visible() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.active = Some(1); - app.mode = Mode::Terminal; - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - - app.active = None; - app.mode = Mode::Terminal; - assert_eq!( - workspace_list_entries(&app), - vec![WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }] - ); - } - - #[test] - fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), - workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), - ]; - app.mode = Mode::Navigate; - app.selected = 1; - app.active = Some(1); - app.collapsed_space_keys.insert("repo-key".into()); - - assert_eq!( - workspace_list_entries(&app), - vec![ - WorkspaceListEntry::Workspace { - ws_idx: 0, - indented: false, - }, - WorkspaceListEntry::Workspace { - ws_idx: 1, - indented: true, - }, - ] - ); - } -} diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index c578e13f..eaec154b 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -1,17 +1,16 @@ -use super::AgentPanelEntry; use crate::config::{ AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken, SpacesSidebarConfig, }; #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct ResolvedToken { +pub(crate) struct ResolvedToken { pub kind: ResolvedTokenKind, pub style: SidebarTokenStyle, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum ResolvedTokenKind { +pub(crate) enum ResolvedTokenKind { StateIcon, StateText(String), Workspace(String), @@ -35,13 +34,24 @@ impl ResolvedToken { } } -pub(super) fn agent_rows( +pub(crate) struct AgentTokenContext<'a> { + pub(crate) workspace: &'a str, + pub(crate) tab: Option<&'a str>, + pub(crate) pane: Option<&'a str>, + pub(crate) agent_label: Option<&'a str>, + pub(crate) terminal_title: Option<&'a str>, + pub(crate) terminal_title_stripped: Option<&'a str>, + pub(crate) canonical_agent: Option, + pub(crate) tokens: &'a std::collections::HashMap, +} + +pub(crate) fn agent_rows( config: &AgentsSidebarConfig, - entry: &AgentPanelEntry, + context: AgentTokenContext<'_>, state_text: &str, ) -> Vec> { config - .rows_for_agent(entry.agent) + .rows_for_agent(context.canonical_agent) .iter() .filter_map(|row| { let resolved = row @@ -54,26 +64,24 @@ pub(super) fn agent_rows( Some(ResolvedTokenKind::StateText(state_text.to_string())) } AgentSidebarToken::Workspace => { - Some(ResolvedTokenKind::Workspace(entry.primary_label.clone())) + Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } - AgentSidebarToken::Tab => { - entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab) - } - AgentSidebarToken::Pane => { - entry.pane_label.clone().map(ResolvedTokenKind::Pane) - } - AgentSidebarToken::Agent => { - entry.agent_label.clone().map(ResolvedTokenKind::Agent) - } - AgentSidebarToken::TerminalTitle => entry + AgentSidebarToken::Tab => context + .tab + .map(|value| ResolvedTokenKind::Tab(value.to_string())), + AgentSidebarToken::Pane => context + .pane + .map(|value| ResolvedTokenKind::Pane(value.to_string())), + AgentSidebarToken::Agent => context + .agent_label + .map(|value| ResolvedTokenKind::Agent(value.to_string())), + AgentSidebarToken::TerminalTitle => context .terminal_title - .clone() - .map(ResolvedTokenKind::TerminalTitle), - AgentSidebarToken::TerminalTitleStripped => entry + .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), + AgentSidebarToken::TerminalTitleStripped => context .terminal_title_stripped - .clone() - .map(ResolvedTokenKind::TerminalTitle), - AgentSidebarToken::Custom(name) => entry + .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())), + AgentSidebarToken::Custom(name) => context .tokens .get(name) .cloned() @@ -88,16 +96,16 @@ pub(super) fn agent_rows( .collect() } -pub(super) struct SpaceTokenContext<'a> { - pub workspace: &'a str, - pub branch: Option<&'a str>, - pub state_text: &'a str, - pub ahead_behind: Option<(usize, usize)>, - pub tokens: &'a std::collections::HashMap, - pub suppress_git_details: bool, +pub(crate) struct SpaceTokenContext<'a> { + pub(crate) workspace: &'a str, + pub(crate) branch: Option<&'a str>, + pub(crate) state_text: &'a str, + pub(crate) ahead_behind: Option<(usize, usize)>, + pub(crate) tokens: &'a std::collections::HashMap, + pub(crate) suppress_git_details: bool, } -pub(super) fn space_rows( +pub(crate) fn space_rows( config: &SpacesSidebarConfig, context: SpaceTokenContext<'_>, ) -> Vec> { @@ -141,7 +149,7 @@ pub(super) fn space_rows( .collect() } -pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { +pub(crate) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { if matches!(previous.kind, ResolvedTokenKind::StateIcon) || matches!(current.kind, ResolvedTokenKind::GitStatus { .. }) { @@ -155,29 +163,44 @@ pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &' mod tests { use super::*; use crate::config::{AgentSidebarToken, SpaceSidebarToken}; - use crate::detect::AgentState; - fn entry() -> AgentPanelEntry { - AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "repo".into(), - primary_tab_label: None, - pane_label: None, + struct Entry { + workspace: String, + tab: Option, + pane: Option, + agent_label: Option, + terminal_title: Option, + terminal_title_stripped: Option, + canonical_agent: Option, + tokens: std::collections::HashMap, + } + + fn entry() -> Entry { + Entry { + workspace: "repo".into(), + tab: None, + pane: None, + agent_label: Some("pi".into()), terminal_title: None, terminal_title_stripped: None, - agent_label: Some("pi".into()), - agent_kind_label: Some("pi".into()), - agent: Some(crate::detect::Agent::Pi), - state: AgentState::Working, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), + canonical_agent: Some(crate::detect::Agent::Pi), tokens: std::collections::HashMap::new(), } } + fn context(entry: &Entry) -> AgentTokenContext<'_> { + AgentTokenContext { + workspace: &entry.workspace, + tab: entry.tab.as_deref(), + pane: entry.pane.as_deref(), + agent_label: entry.agent_label.as_deref(), + terminal_title: entry.terminal_title.as_deref(), + terminal_title_stripped: entry.terminal_title_stripped.as_deref(), + canonical_agent: entry.canonical_agent, + tokens: &entry.tokens, + } + } + #[test] fn missing_custom_tokens_elide_rows_and_separators() { let entry = entry(); @@ -193,7 +216,7 @@ mod tests { ..Default::default() }; - let rows = agent_rows(&config, &entry, "working"); + let rows = agent_rows(&config, context(&entry), "working"); assert_eq!(rows.len(), 2); assert_eq!( @@ -223,7 +246,7 @@ mod tests { }; assert_eq!( - agent_rows(&config, &entry, "deep in the mines"), + agent_rows(&config, context(&entry), "deep in the mines"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())), ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())), @@ -249,7 +272,7 @@ mod tests { }; assert_eq!( - agent_rows(&config, &entry, "working"), + agent_rows(&config, context(&entry), "working"), vec![vec![ ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())), ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())), @@ -271,15 +294,15 @@ mod tests { pi.agent_label = Some("renamed pi".into()); assert_eq!( - agent_rows(&config, &pi, "working"), + agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( "renamed pi".into() ))]] ); - pi.agent = None; + pi.canonical_agent = None; assert_eq!( - agent_rows(&config, &pi, "working"), + agent_rows(&config, context(&pi), "working"), vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( "repo".into() ))]] diff --git a/src/ui/status.rs b/src/ui/status.rs index 62646482..e47e0f90 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -1,17 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, + widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ - app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, - config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition}, - detect::AgentState, + app::state::{CopyFeedback, Palette}, + config::ToastClipboardPosition, }; pub(crate) fn copy_feedback_rect( @@ -20,7 +18,7 @@ pub(crate) fn copy_feedback_rect( offset_rows: u16, position: ToastClipboardPosition, ) -> Rect { - if area.width == 0 || area.height == 0 { + if area.is_empty() { return Rect::default(); } @@ -49,129 +47,56 @@ pub(crate) fn copy_feedback_rect( Rect::new(x, y, width, height) } -pub(crate) fn toast_notification_rect( - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, -) -> Rect { - let content_width = display_width_u16(&toast.title) - .max(display_width_u16(&toast.context)) - .saturating_add(4); - let width = content_width.saturating_add(2).min(area.width); - let content_height = if toast.context.is_empty() { 1 } else { 2 }; - let height = (content_height + 2).min(area.height); - let x = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x, - ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => { - area.x + area.width.saturating_sub(width) - } - }; - let warning_offset = u16::from(offset_for_warning); - let y = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => { - area.y + warning_offset.min(area.height) - } - ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => { - area.y + area.height.saturating_sub(height + warning_offset) - } - }; - Rect::new(x, y, width, height) -} - -pub(super) fn render_toast_notification( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, - p: &Palette, -) { - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let toast_area = toast_notification_rect(area, toast, offset_for_warning, position); - - frame.render_widget(Clear, toast_area); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(p.overlay0)) - .style(Style::default().bg(p.panel_bg)); - let inner = block.inner(toast_area); - frame.render_widget(block, toast_area); - - if inner.height < 1 { - return; - } - - let [title_row, context_row] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - - let title = Line::from(vec![ - Span::styled("●", Style::default().fg(dot_color)), - Span::raw(" "), - Span::styled( - &toast.title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - ]); - let context = Line::from(vec![ - Span::styled(" ", Style::default().fg(p.overlay0)), - Span::styled(&toast.context, Style::default().fg(p.overlay0)), - ]); - - frame.render_widget(Paragraph::new(title), title_row); - if !toast.context.is_empty() && inner.height >= 2 { - frame.render_widget(Paragraph::new(context), context_row); - } -} - -pub(super) fn render_copy_feedback( - frame: &mut Frame, +pub(crate) fn render_copy_feedback_buffer( + buffer: &mut Buffer, area: Rect, feedback: &CopyFeedback, offset_rows: u16, position: ToastClipboardPosition, - p: &Palette, + palette: &Palette, ) { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { return; } - frame.render_widget(Clear, feedback_area); + Clear.render(feedback_area, buffer); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(p.green)) - .style(Style::default().bg(p.panel_bg)); + .border_style(Style::default().fg(palette.green)) + .style(Style::default().bg(palette.panel_bg)); let inner = block.inner(feedback_area); - frame.render_widget(block, feedback_area); + block.render(feedback_area, buffer); if inner.height == 0 { return; } let text = Line::from(vec![ - Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)), + Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)), Span::raw(" "), Span::styled( &feedback.message, Style::default() - .fg(p.text) - .bg(p.panel_bg) + .fg(palette.text) + .bg(palette.panel_bg) .add_modifier(Modifier::BOLD), ), ]); - frame.render_widget(Paragraph::new(text), inner); + Paragraph::new(text).render(inner, buffer); } -pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { +pub(crate) fn render_config_diagnostic_buffer( + buffer: &mut Buffer, + area: Rect, + message: &str, + palette: &Palette, +) -> u16 { let style = Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) + .fg(panel_contrast_fg(palette)) + .bg(palette.yellow) .add_modifier(Modifier::BOLD); + let mut rendered_rows = 0u16; for (row, line) in message .lines() @@ -181,176 +106,41 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: & { let text = format!(" {line} "); let width = (text.len() as u16).min(area.width); - let notif_area = Rect::new( + let diagnostic_area = Rect::new( area.x + area.width.saturating_sub(width), area.y + row as u16, width, 1, ); - frame.render_widget(Clear, notif_area); - frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area); + Clear.render(diagnostic_area, buffer); + Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer); + rendered_rows = rendered_rows.saturating_add(1); } -} -pub(super) fn state_icon_symbol( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, -) -> &'static str { - match (indicator_style, state, seen) { - (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·", - (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×", - (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐", - (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓", - (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·", - } -} - -pub(super) fn state_icon( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, - p: &Palette, -) -> (&'static str, Style) { - ( - state_icon_symbol(state, seen, indicator_style), - Style::default().fg(state_label_color(state, seen, p)), - ) -} - -pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "idle", - } -} - -pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color { - match (state, seen) { - (AgentState::Blocked, _) => p.red, - (AgentState::Working, _) => p.yellow, - (AgentState::Idle, false) => p.teal, - (AgentState::Idle, true) => p.green, - (AgentState::Unknown, _) => p.overlay0, - } + rendered_rows } #[cfg(test)] mod tests { use super::*; - use crate::config::{ToastClipboardPosition, ToastHerdrPosition}; - - fn toast() -> ToastNotification { - ToastNotification { - kind: ToastKind::Finished, - title: "done".to_string(), - context: "workspace".to_string(), - position: None, - target: None, - } - } - - fn feedback() -> CopyFeedback { - CopyFeedback { - message: "copied to clipboard".to_string(), - } - } - - #[test] - fn state_icons_support_dot_and_distinct_symbol_styles() { - let palette = Palette::catppuccin(); - for (indicator_style, expected_symbols) in [ - (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]), - (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]), - ] { - for ((state, seen, color), expected_symbol) in [ - (AgentState::Blocked, true, palette.red), - (AgentState::Working, true, palette.yellow), - (AgentState::Idle, false, palette.teal), - (AgentState::Idle, true, palette.green), - (AgentState::Unknown, true, palette.overlay0), - ] - .into_iter() - .zip(expected_symbols) - { - let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette); - assert_eq!(actual_symbol, expected_symbol); - assert_eq!(display_width_u16(actual_symbol), 1); - assert_eq!(style.fg, Some(color)); - } - } - } - - #[test] - fn toast_rect_uses_configured_corner() { - let area = Rect::new(10, 20, 100, 40); - let toast = toast(); - - let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft); - assert_eq!(top_left.x, area.x); - assert_eq!(top_left.y, area.y); - - let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - assert_eq!(top_right.x + top_right.width, area.x + area.width); - assert_eq!(top_right.y, area.y); - - let bottom_left = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft); - assert_eq!(bottom_left.x, area.x); - assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height); - - let bottom_right = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight); - assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width); - assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height); - } - - #[test] - fn toast_rect_uses_display_width_for_cjk_labels() { - let area = Rect::new(0, 0, 100, 20); - let toast = ToastNotification { - kind: ToastKind::NeedsAttention, - title: "重构用户认证模块".to_string(), - context: "提交 herdr 的反馈".to_string(), - position: None, - target: None, - }; - - let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - - let expected_content_width = - display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6; - assert_eq!(rect.width, expected_content_width); - assert_eq!(rect.x + rect.width, area.x + area.width); - } #[test] fn copy_feedback_rect_uses_configured_position() { let area = Rect::new(10, 20, 100, 40); - let feedback = feedback(); + let feedback = CopyFeedback { + message: "copied to clipboard".to_owned(), + }; - let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); - assert_eq!(top_center.y, area.y); - assert_eq!( - top_center.x, - area.x + area.width.saturating_sub(top_center.width) / 2 - ); + let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); + assert_eq!(top.y, area.y); + assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2); - let bottom_center = - copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); - assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height); + let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); + assert_eq!(bottom.bottom(), area.bottom()); assert_eq!( - bottom_center.x, - area.x + area.width.saturating_sub(bottom_center.width) / 2 + bottom.x, + area.x + area.width.saturating_sub(bottom.width) / 2 ); } } diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index 99ca9727..766c1d83 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -1,8 +1,7 @@ use ratatui::{layout::Rect, Frame}; use super::panes::{compute_pane_infos, render_panes, resize_tab_panes}; -use crate::app::state::ViewState; -use crate::app::{AppState, Mode}; +use crate::app::AppState; use crate::layout::{PaneInfo, SplitBorder}; use crate::protocol::CursorState; use crate::terminal::TerminalRuntimeRegistry; @@ -18,15 +17,6 @@ pub(crate) struct TabSurfaceView<'a> { pub(crate) split_borders: &'a [SplitBorder], } -impl ViewState { - pub(crate) fn tab_surface(&self) -> TabSurfaceView<'_> { - TabSurfaceView { - pane_infos: &self.pane_infos, - split_borders: &self.split_borders, - } - } -} - pub(crate) fn compute_tab_surface( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -105,10 +95,6 @@ pub(crate) fn tab_surface_cursor( terminal_runtimes: &TerminalRuntimeRegistry, surface: TabSurfaceView<'_>, ) -> Option { - if app.mode != Mode::Terminal { - return None; - } - let ws_idx = app.active?; let info = surface.pane_infos.iter().find(|info| info.is_focused)?; if !app.pane_exposes_host_cursor(ws_idx, info.id) { @@ -189,12 +175,9 @@ mod tests { app.workspaces = vec![workspace]; app.active = Some(0); app.selected = 0; - app.mode = Mode::Terminal; let full_area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app, full_area); - let area = app.view.terminal_area; - assert_eq!(area, Rect::new(26, 1, 80, 19)); + let area = full_area; let surface = compute_tab_surface( &app, &TerminalRuntimeRegistry::new(), @@ -207,7 +190,6 @@ mod tests { app.view.terminal_area = Rect::new(9, 8, 7, 6); app.view.pane_infos.clear(); - app.view.split_borders.clear(); let surface_view = TabSurfaceView { pane_infos: &surface.pane_infos, @@ -238,90 +220,4 @@ mod tests { .any(|(_, symbol, link)| { symbol == "L" && link == uri })); assert!(tab_surface_cursor(&app, &TerminalRuntimeRegistry::new(), surface_view,).is_some()); } - - fn full_app_frame(app: &mut AppState, area: Rect) -> crate::protocol::FrameData { - let (buffer, cursor) = crate::server::render_stream::render_virtual(app, area, true); - let hyperlinks = - crate::server::render_stream::visible_hyperlinks(app, &TerminalRuntimeRegistry::new()); - crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, - cursor, - &hyperlinks, - ) - } - - fn frame_digest(frame: &crate::protocol::FrameData) -> String { - use sha2::{Digest, Sha256}; - - let encoded = bincode::serde::encode_to_vec(frame, bincode::config::standard()).unwrap(); - format!("{:x}", Sha256::digest(encoded)) - } - - fn full_app_characterization_state(uri: &str) -> AppState { - let mut workspace = Workspace::test_new("characterization"); - workspace.identity_cwd = std::path::PathBuf::from("characterization"); - workspace.cached_git_branch = None; - workspace.cached_git_ahead_behind = None; - workspace.cached_git_space = None; - workspace.test_add_tab(Some("logs")); - workspace.switch_tab(0); - let left = workspace.tabs[0].root_pane; - let right = workspace.test_split(Direction::Horizontal); - workspace.insert_test_runtime( - left, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - 40, - 10, - format!("\x1b]8;;{uri}\x1b\\LINK\x1b]8;;\x1b\\").as_bytes(), - ), - ); - workspace.insert_test_runtime( - right, - crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 10, b"RIGHT\r\nPANE"), - ); - - let mut app = AppState::test_new(); - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - app - } - - #[tokio::test] - async fn desktop_full_app_semantic_frame_is_characterized() { - let uri = "https://example.com/full-app"; - let mut app = full_app_characterization_state(uri); - let frame = full_app_frame(&mut app, Rect::new(0, 0, 106, 20)); - - assert_eq!((frame.width, frame.height), (106, 20)); - assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 26, 20)); - assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 80, 1)); - assert_eq!(app.view.terminal_area, Rect::new(26, 1, 80, 19)); - assert_eq!(app.view.pane_infos.len(), 2); - assert!(!app.view.split_borders.is_empty()); - assert!(frame.cursor.is_some()); - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!( - frame_digest(&frame), - "a7c21fa42305a41231c7ae254f264f6ef923f46301d8fc4cd35ab6dfdd651b6b" - ); - } - - #[tokio::test] - async fn mobile_full_app_semantic_frame_is_characterized() { - let mut app = full_app_characterization_state("https://example.com/mobile"); - app.mode = Mode::Navigate; - let frame = full_app_frame(&mut app, Rect::new(0, 0, 44, 20)); - - assert_eq!((frame.width, frame.height), (44, 20)); - assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile); - assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2)); - assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); - assert_eq!(frame.cursor, None); - assert_eq!( - frame_digest(&frame), - "295608a66067f1e1f066c0adb3cf427e8a2d68bba8f68949fb72d464dcd8baab" - ); - } } diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs deleted file mode 100644 index 603422fb..00000000 --- a/src/ui/tabs.rs +++ /dev/null @@ -1,788 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - widgets::Paragraph, - Frame, -}; - -use super::text::display_width_u16; -use super::widgets::panel_contrast_fg; -use crate::app::AppState; - -const MIN_TAB_WIDTH: u16 = 8; -const NEW_TAB_WIDTH: u16 = 3; -const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; -const ZOOM_INDICATOR: &str = "ZOOM"; -// The narrowest overflowing tab strip worth keeping interactive: one -// minimum-width tab, both scroll controls, and the new-tab control. -const MIN_TAB_STRIP_WIDTH: u16 = - MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); - -#[derive(Debug, Clone, Default)] -pub(crate) struct TabBarView { - pub scroll: usize, - pub tab_hit_areas: Vec, - pub scroll_left_hit_area: Rect, - pub scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, -} - -fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { - display_width_u16(&tab_chrome_label(ws, tab_idx)) - .saturating_add(4) - .max(MIN_TAB_WIDTH) -} - -fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String { - let name = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - if ws.tabs.get(tab_idx).is_some_and(|tab| tab.zoomed) { - format!("{name} Z") - } else { - name - } -} - -#[derive(Clone, Copy)] -struct VisibleStatusSegment<'a> { - text: &'a str, - accent: bool, -} - -fn visible_status_segments(app: &AppState) -> Vec> { - let zoomed = app - .active - .and_then(|index| app.workspaces.get(index)) - .is_some_and(|workspace| workspace.zoomed); - app.tab_bar_right - .iter() - .filter_map(|segment| match segment { - crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment { - text: ZOOM_INDICATOR, - accent: true, - }), - crate::app::state::TabBarStatusSegment::Text(Some(text)) - if display_width_u16(text) > 0 => - { - Some(VisibleStatusSegment { - text, - accent: false, - }) - } - crate::app::state::TabBarStatusSegment::Zoom - | crate::app::state::TabBarStatusSegment::Text(_) => None, - }) - .collect() -} - -fn tab_bar_status_width(app: &AppState) -> u16 { - let segments = visible_status_segments(app); - let content_width = segments.iter().fold(0_u16, |width, segment| { - width.saturating_add(display_width_u16(segment.text)) - }); - let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX); - content_width - .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators)) -} - -fn tab_bar_status_area(app: &AppState, area: Rect) -> Option { - let width = tab_bar_status_width(app); - if width == 0 { - return None; - } - let reserved = width.saturating_add(1); - (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) - .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)) -} - -// Tabs win over status decoration on narrow rows. The extra reserved cell is -// the gap between the interactive strip and the right-aligned status entries. -pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect { - let reserved = tab_bar_status_area(app, area) - .map(|status| status.width.saturating_add(1)) - .unwrap_or(0); - Rect { - width: area.width.saturating_sub(reserved), - ..area - } -} - -fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec { - let mut rects = vec![Rect::default(); ws.tabs.len()]; - if area.width == 0 || area.height == 0 { - return rects; - } - - let mut x = area.x; - let right = area.x + area.width; - for (idx, rect) in rects.iter_mut().enumerate().skip(scroll) { - if x >= right { - break; - } - let desired = tab_width(ws, idx); - let remaining = right.saturating_sub(x); - let width = desired.min(remaining).max(1); - *rect = Rect::new(x, area.y, width, 1); - x = x.saturating_add(width + 1); - } - rects -} - -fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - let mut best_scroll = ws.active_tab; - let mut best_distance = u16::MAX; - let viewport_center = area.x.saturating_mul(2).saturating_add(area.width); - - for scroll in 0..=ws.active_tab { - let rects = layout_tab_hit_areas(ws, area, scroll); - let Some(active_rect) = rects.get(ws.active_tab).copied() else { - continue; - }; - if active_rect.width == 0 { - continue; - } - - let active_center = active_rect - .x - .saturating_mul(2) - .saturating_add(active_rect.width); - let distance = active_center.abs_diff(viewport_center); - if distance <= best_distance { - best_distance = distance; - best_scroll = scroll; - } - } - - best_scroll -} - -fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 { - tab_hit_areas - .iter() - .rev() - .find(|rect| rect.width > 0) - .map(|rect| rect.x + rect.width) - .unwrap_or(fallback_x) -} - -fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize { - (0..ws.tabs.len()) - .find(|&scroll| { - layout_tab_hit_areas(ws, area, scroll) - .last() - .is_some_and(|rect| rect.width > 0) - }) - .unwrap_or(0) -} - -pub(crate) fn compute_tab_bar_view( - ws: &crate::workspace::Workspace, - area: Rect, - current_scroll: usize, - follow_active: bool, - mouse_chrome: bool, -) -> TabBarView { - if area.width == 0 || area.height == 0 { - return TabBarView::default(); - } - - if !mouse_chrome { - let max_scroll = max_tab_scroll(ws, area); - let scroll = if follow_active { - centered_tab_scroll(ws, area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - return TabBarView { - scroll, - tab_hit_areas: layout_tab_hit_areas(ws, area, scroll), - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), - }; - } - - let area_right = area.x + area.width; - let all_tabs_area = Rect::new( - area.x, - area.y, - area.width.saturating_sub(NEW_TAB_WIDTH), - area.height, - ); - let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0); - let overflow = all_tabs.iter().any(|rect| rect.width == 0); - if !overflow { - let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x); - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - return TabBarView { - scroll: 0, - tab_hit_areas: all_tabs, - scroll_left_hit_area: Rect::default(), - scroll_right_hit_area: Rect::default(), - new_tab_hit_area, - }; - } - - let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1); - let tab_area_x = left_hit_area.x + left_hit_area.width; - let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH); - let tab_area_right = area_right.saturating_sub(reserved_trailing_width); - let tab_area = Rect::new( - tab_area_x, - area.y, - tab_area_right.saturating_sub(tab_area_x), - area.height, - ); - - let max_scroll = max_tab_scroll(ws, tab_area); - let scroll = if follow_active { - centered_tab_scroll(ws, tab_area).min(max_scroll) - } else { - current_scroll.min(max_scroll) - }; - let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll); - let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right); - let right_hit_area = Rect::new( - trailing_x, - area.y, - area_right - .saturating_sub(trailing_x) - .min(TAB_SCROLL_BUTTON_WIDTH), - 1, - ); - let new_tab_x = right_hit_area.x + right_hit_area.width; - let new_tab_hit_area = Rect::new( - new_tab_x, - area.y, - area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH), - 1, - ); - - TabBarView { - scroll, - tab_hit_areas, - scroll_left_hit_area: left_hit_area, - scroll_right_hit_area: right_hit_area, - new_tab_hit_area, - } -} - -fn tab_drop_indicator_x( - app: &AppState, - ws: &crate::workspace::Workspace, - insert_idx: usize, -) -> Option { - let mut visible_tabs = app - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0); - let first_visible = visible_tabs.clone().next()?; - let last_visible = visible_tabs.next_back().unwrap_or(first_visible); - - if insert_idx == 0 { - return Some(if first_visible.0 == 0 { - first_visible.1.x - } else { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - }); - } - - if let Some((_, rect)) = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(idx, rect)| *idx == insert_idx && rect.width > 0) - { - return Some(rect.x.saturating_sub(1)); - } - - if insert_idx >= ws.tabs.len() { - return Some(if last_visible.0 + 1 >= ws.tabs.len() { - last_visible.1.x + last_visible.1.width - } else { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }); - } - - None -} - -pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { - if area.width == 0 || area.height == 0 { - return; - } - let Some(active_ws_idx) = app.active else { - return; - }; - let Some(ws) = app.workspaces.get(active_ws_idx) else { - return; - }; - let p = &app.palette; - - frame.render_widget( - Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)), - area, - ); - - let first_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let last_visible_idx = app - .view - .tab_hit_areas - .iter() - .enumerate() - .rev() - .find(|(_, rect)| rect.width > 0) - .map(|(idx, _)| idx); - let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0; - let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0 - && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()); - - if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - let style = if can_scroll_left { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" < ").style(style), - app.view.tab_scroll_left_hit_area, - ); - } - - if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - let style = if can_scroll_right { - Style::default().fg(p.overlay1).bg(p.surface0) - } else { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - }; - frame.render_widget( - Paragraph::new(" > ").style(style), - app.view.tab_scroll_right_hit_area, - ); - } - - for (idx, tab) in ws.tabs.iter().enumerate() { - let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else { - break; - }; - if rect.width == 0 { - continue; - } - let active = idx == ws.active_tab; - let style = if active { - let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent); - if tab.is_auto_named() { - base - } else { - base.add_modifier(Modifier::BOLD) - } - } else if tab.is_auto_named() { - Style::default() - .fg(p.overlay0) - .bg(p.surface0) - .add_modifier(Modifier::DIM) - } else { - Style::default().fg(p.overlay1).bg(p.surface0) - }; - let width = rect.width as usize; - let name = tab_chrome_label(ws, idx); - // Pad by terminal columns, not chars, so wide glyphs stay centered. - let padding = width.saturating_sub(display_width_u16(&name) as usize); - let left = padding / 2; - let text = format!( - "{empty:left$}{name}{empty:right$}", - empty = "", - right = padding - left - ); - frame.render_widget(Paragraph::new(text).style(style), rect); - } - - if let Some(crate::app::state::DragState { - target: - crate::app::state::DragTarget::TabReorder { - ws_idx, - insert_idx: Some(insert_idx), - .. - }, - }) = &app.drag - { - if *ws_idx == active_ws_idx { - if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) { - frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)] - .set_symbol("│") - .set_style(Style::default().fg(p.accent)); - } - } - } - - if app.mouse_capture && app.view.new_tab_hit_area.width > 0 { - frame.render_widget( - Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), - app.view.new_tab_hit_area, - ); - } - - if first_visible_idx.is_some_and(|idx| idx > 0) { - let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { - app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width - } else { - area.x - }; - if x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { - let content = tab_bar_content_area(app, area); - let content_right = content.x + content.width; - let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { - app.view.tab_scroll_right_hit_area.x.saturating_sub(1) - } else { - content_right.saturating_sub(1) - }; - if x >= area.x && x < area.x + area.width { - frame.buffer_mut()[(x, area.y)] - .set_symbol("…") - .set_style(Style::default().fg(p.overlay0)); - } - } - - if let Some(status_area) = tab_bar_status_area(app, area) { - let segments = visible_status_segments(app); - let separator_width = display_width_u16(&app.tab_bar_right_separator); - let mut x = status_area.x; - for (index, segment) in segments.iter().enumerate() { - if index > 0 && separator_width > 0 { - let rect = Rect::new(x, area.y, separator_width, 1); - frame.render_widget( - Paragraph::new(app.tab_bar_right_separator.as_str()) - .style(Style::default().fg(p.overlay0).bg(p.panel_bg)), - rect, - ); - x = x.saturating_add(separator_width); - } - - let width = display_width_u16(segment.text); - let rect = Rect::new(x, area.y, width, 1); - let style = if segment.accent { - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.overlay1).bg(p.panel_bg) - }; - frame.render_widget(Paragraph::new(segment.text).style(style), rect); - x = x.saturating_add(width); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::state::AppState; - use crate::workspace::Workspace; - use ratatui::{backend::TestBackend, Terminal}; - - fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String { - (area.x..area.x + area.width) - .map(|x| buffer[(x, row)].symbol()) - .collect::() - .trim_end() - .to_string() - } - - #[test] - fn tab_bar_marks_zoomed_tabs_without_renaming_them() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - let custom_tab = ws.test_add_tab(Some("test")); - ws.tabs[custom_tab].zoomed = true; - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains(" 1 Z"), "tab row: {row:?}"); - assert!(row.contains(" test Z"), "tab row: {row:?}"); - assert_eq!(app.workspaces[0].tab_display_name(0).as_deref(), Some("1")); - assert_eq!( - app.workspaces[0].tab_display_name(custom_tab).as_deref(), - Some("test") - ); - } - - #[test] - fn tab_bar_renders_ordered_status_entries_with_separator() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].zoomed = true; - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.tab_bar_right_separator = " · ".into(); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 60, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas.clone(); - - let backend = TestBackend::new(60, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let buffer = terminal.backend().buffer(); - let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0); - assert!( - row.ends_with("ZOOM · wintermute · 14:30"), - "tab row: {row:?}" - ); - let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30"); - assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent)); - for rect in &view.tab_hit_areas { - assert!(rect.x + rect.width <= content.x + content.width); - } - } - - #[test] - fn hidden_status_entries_do_not_leave_dangling_separators() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Zoom, - crate::app::state::TabBarStatusSegment::Text(None), - crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), - ]; - app.tab_bar_right_separator = " | ".into(); - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 40, 1); - let content = tab_bar_content_area(&app, app.view.tab_bar_rect); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(40, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.ends_with("wintermute"), "tab row: {row:?}"); - assert!(!row.contains(" | "), "tab row: {row:?}"); - } - - #[test] - fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some( - "x".into(), - ))]; - let mut workspace = Workspace::test_new("test"); - workspace.test_add_tab(None); - workspace.test_add_tab(None); - app.workspaces = vec![workspace]; - app.active = Some(0); - - let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1); - assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow); - - let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1); - let content = tab_bar_content_area(&app, wide_enough); - assert_eq!(content.width, MIN_TAB_STRIP_WIDTH); - let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true); - assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH); - } - - #[test] - fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() { - let mut app = AppState::test_new(); - app.tab_bar_right = vec![ - crate::app::state::TabBarStatusSegment::Text(Some( - "a-hostname-wider-than-the-whole-bar".into(), - )), - crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), - ]; - app.workspaces = vec![Workspace::test_new("test")]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - - assert_eq!( - tab_bar_content_area(&app, app.view.tab_bar_rect), - app.view.tab_bar_rect - ); - assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None); - - let view = compute_tab_bar_view( - &app.workspaces[0], - tab_bar_content_area(&app, app.view.tab_bar_rect), - 0, - true, - true, - ); - assert!(view.tab_hit_areas[0].width > 0); - assert!(view.new_tab_hit_area.width > 0); - } - - #[test] - fn cjk_tab_labels_are_centered_by_display_width() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - // 17 display columns + 4 padding: two columns each side, wide glyphs - // starting right after the left padding. - let rect = app.view.tab_hit_areas[0]; - assert_eq!(rect.width, 21); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(rect.x, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 1, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + 2, rect.y)].symbol(), "提"); - assert_eq!(buffer[(rect.x + rect.width - 2, rect.y)].symbol(), " "); - assert_eq!(buffer[(rect.x + rect.width - 1, rect.y)].symbol(), " "); - } - - #[test] - fn tab_labels_are_centered_in_their_cells() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("omarchy".into()); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let rect = app.view.tab_hit_areas[0]; - let buffer = terminal.backend().buffer(); - let cell: String = (rect.x..rect.x + rect.width) - .map(|x| buffer[(x, rect.y)].symbol()) - .collect(); - assert_eq!(cell, " omarchy "); - } - - #[test] - fn active_auto_named_tab_keeps_readable_weight() { - let mut app = AppState::test_new(); - let ws = Workspace::test_new("test"); - - app.workspaces = vec![ws]; - app.active = Some(0); - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let tab_rect = app.view.tab_hit_areas[0]; - let style = terminal.backend().buffer()[(tab_rect.x + 1, tab_rect.y)].style(); - - assert_eq!(style.bg, Some(app.palette.accent)); - assert!(!style.add_modifier.contains(Modifier::DIM)); - assert!(!style.add_modifier.contains(Modifier::BOLD)); - } - - #[test] - fn zoom_marker_counts_toward_tab_width() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("abcdefgh".into()); - ws.tabs[0].zoomed = true; - - assert_eq!(tab_width(&ws, 0), 14); - } - - #[test] - fn tab_width_uses_display_width_for_cjk_labels() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - assert_eq!( - tab_width(&ws, 0), - display_width_u16("提交 herdr 的反馈") + 4 - ); - } - - #[test] - fn tab_bar_renders_trailing_cjk_character() { - let mut app = AppState::test_new(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); - - app.active = Some(0); - app.workspaces = vec![ws]; - app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); - let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); - app.view.tab_hit_areas = view.tab_hit_areas; - - let backend = TestBackend::new(30, 1); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) - .unwrap(); - - let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); - assert!(row.contains('馈'), "tab row: {row:?}"); - } -} diff --git a/src/ui/text.rs b/src/ui/text.rs index 45f7983b..9b5a7c68 100644 --- a/src/ui/text.rs +++ b/src/ui/text.rs @@ -4,10 +4,6 @@ pub(crate) fn display_width(text: &str) -> usize { UnicodeWidthStr::width(text) } -pub(crate) fn display_width_u16(text: &str) -> u16 { - display_width(text).min(u16::MAX as usize) as u16 -} - pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { if display_width(text) <= max_width { return text.to_string(); @@ -23,22 +19,6 @@ pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { format!("{prefix}…") } -pub(crate) fn middle_elide(text: &str, max_width: usize) -> String { - if display_width(text) <= max_width { - return text.to_string(); - } - if max_width <= 1 { - return "…".to_string(); - } - - let content_width = max_width.saturating_sub(1); - let left_width = content_width / 2; - let right_width = content_width.saturating_sub(left_width); - let prefix = take_prefix_width(text, left_width); - let suffix = take_suffix_width(text, right_width); - format!("{prefix}…{suffix}") -} - fn take_prefix_width(text: &str, max_width: usize) -> String { let mut output = String::new(); let mut width = 0usize; @@ -53,20 +33,6 @@ fn take_prefix_width(text: &str, max_width: usize) -> String { output } -fn take_suffix_width(text: &str, max_width: usize) -> String { - let mut output = Vec::new(); - let mut width = 0usize; - for ch in text.chars().rev() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > max_width { - break; - } - output.push(ch); - width += ch_width; - } - output.into_iter().rev().collect() -} - #[cfg(test)] mod tests { use super::*; @@ -78,12 +44,4 @@ mod tests { assert_eq!(text, "提交 herdr 的反…"); assert!(display_width(&text) <= 16); } - - #[test] - fn middle_elide_uses_display_width() { - let text = middle_elide("重构用户认证模块并迁移到统一登录服务", 12); - - assert!(text.contains('…')); - assert!(display_width(&text) <= 12); - } } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index ef416134..33fcc9a0 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,70 +1,30 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, - Frame, + layout::{Constraint, Layout, Rect}, + style::Color, }; use crate::app::state::Palette; -pub(super) fn render_panel_shell( - frame: &mut Frame, - area: Rect, - border_color: Color, - bg: Color, -) -> Option { - if area.width < 2 || area.height < 2 { - return None; - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .border_set(ratatui::symbols::border::PLAIN) - .style(Style::default().bg(bg)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - Some(inner) -} - -pub(super) fn panel_contrast_fg(p: &Palette) -> Color { - match p.panel_bg { - Color::Reset => p.surface_dim, +pub(super) fn panel_contrast_fg(palette: &Palette) -> Color { + match palette.panel_bg { + Color::Reset => palette.surface_dim, color => color, } } -pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option { - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { +pub(crate) fn centered_popup_rect(area: Rect, popup_width: u16, popup_height: u16) -> Option { + let popup_width = popup_width.min(area.width.saturating_sub(4)); + let popup_height = popup_height.min(area.height.saturating_sub(2)); + if popup_width < 4 || popup_height < 4 { return None; } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - Some(Rect::new(popup_x, popup_y, popup_w, popup_h)) -} - -pub(super) fn render_modal_shell( - frame: &mut Frame, - area: Rect, - popup_w: u16, - popup_h: u16, - p: &Palette, -) -> Option { - let popup = centered_popup_rect(area, popup_w, popup_h)?; - render_panel_shell(frame, popup, p.accent, p.panel_bg) -} - -pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - let line = Line::from(vec![Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - )]); - frame.render_widget(Paragraph::new(line), area); + Some(Rect::new( + area.x + area.width.saturating_sub(popup_width) / 2, + area.y + area.height.saturating_sub(popup_height) / 2, + popup_width, + popup_height, + )) } #[derive(Debug, Clone, Copy)] @@ -110,169 +70,40 @@ pub(crate) fn modal_stack_areas( } let areas = Layout::vertical(constraints).split(inner); - let mut header = Rect::default(); - let mut content = Rect::default(); - let mut footer = None; - let mut actions = None; - + let mut result = ModalStackAreas { + header: Rect::default(), + content: Rect::default(), + footer: None, + actions: None, + }; for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) { match slot { - Slot::Header => header = area, - Slot::Content => content = area, - Slot::Footer => footer = Some(area), - Slot::Actions => actions = Some(area), + Slot::Header => result.header = area, + Slot::Content => result.content = area, + Slot::Footer => result.footer = Some(area), + Slot::Actions => result.actions = Some(area), } } - - ModalStackAreas { - header, - content, - footer, - actions, - } + result } -pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String { +fn action_button_width(hint: Option<&str>, label: &str) -> u16 { match hint { - Some(hint) => format!(" {hint} {label} "), - None => format!(" {label} "), + Some(hint) => format!(" {hint} {label} ").chars().count() as u16, + None => format!(" {label} ").chars().count() as u16, } } -pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 { - action_button_text(hint, label).chars().count() as u16 +pub(crate) fn close_button_rect(area: Rect) -> Rect { + let width = action_button_width(Some("esc"), "close"); + Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1) } -pub(crate) struct ActionButtonSpec<'a> { - pub hint: Option<&'a str>, - pub label: &'a str, -} - -pub(crate) fn action_button_row_rects( - area: Rect, - buttons: &[ActionButtonSpec<'_>], - gap: u16, - row_offset: u16, -) -> Vec { - let widths: Vec = buttons - .iter() - .map(|button| action_button_width(button.hint, button.label)) - .collect(); - centered_button_row(area, &widths, gap, row_offset) -} - -pub(super) fn render_action_button( - frame: &mut Frame, - rect: Rect, - hint: Option<&str>, - label: &str, - style: Style, -) { - frame.render_widget( - Paragraph::new(action_button_text(hint, label)) - .style(style) - .alignment(Alignment::Center), - rect, - ); -} - -pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { - frame.render_widget( - Paragraph::new(format!(" {text}")) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); -} - -pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec { - let mut rows = Vec::with_capacity(count); - let mut y = area.y; - for _ in 0..count { - if y >= area.y + area.height { - break; - } - let remaining = area.y + area.height - y; - let height = row_height.min(remaining); - rows.push(Rect::new(area.x, y, area.width, height)); - y = y.saturating_add(row_height); - } - rows -} - -pub(crate) fn render_modal_choice_list( - frame: &mut Frame, - area: Rect, - title: &str, - description: &str, - options: &[(&str, T)], - current_value: T, - selected_idx: usize, - p: &Palette, - row_height: u16, -) where - T: Copy + PartialEq, -{ - let [desc_area, _, list_area] = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(1), - Constraint::Min(2), - ]) - .areas::<3>(area); - - render_modal_description( - frame, - desc_area, - description, - Style::default().fg(p.overlay1), - ); - - let rows = modal_choice_rows(list_area, options.len(), row_height); - for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { - let is_active = *value == current_value; - let is_selected = idx == selected_idx; - let marker = if is_active { " ✓" } else { "" }; - let style = if is_selected { - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - frame.render_widget( - Paragraph::new(format!(" {title}: {label}{marker}")) - .style(style) - .wrap(Wrap { trim: false }), - *row, - ); - } -} - -pub(super) fn centered_button_row( - inner: Rect, - widths: &[u16], - gap: u16, - row_offset: u16, -) -> Vec { - let total_w = widths - .iter() - .copied() - .sum::() - .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16)); - let mut x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + row_offset.min(inner.height.saturating_sub(1)); - widths - .iter() - .map(|w| { - let rect = Rect::new( - x, - y, - (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))), - 1, - ); - x = x.saturating_add(*w).saturating_add(gap); - rect - }) - .collect() +pub(crate) fn continue_button_rect(area: Rect) -> Rect { + Rect::new( + area.x, + area.y, + action_button_width(Some("↵"), "continue"), + 1, + ) } diff --git a/src/workspace.rs b/src/workspace.rs index 0a719ab1..b4bba45d 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -19,8 +19,6 @@ mod aggregate; mod git; mod tab; -#[cfg(test)] -use self::git::git_ahead_behind; use self::git::git_status_cache_key_for_space; pub(crate) use self::{git::git_status_snapshot_for_cwd_with_demand, tab::MovedPane}; pub use self::{ @@ -274,9 +272,6 @@ impl Workspace { } } - // Test modules construct workspaces through the default constructor; production paths - // use the env-aware variant so pane identity env is always explicit. - #[cfg_attr(not(test), allow(dead_code))] pub fn new( initial_cwd: PathBuf, rows: u16, @@ -289,7 +284,7 @@ impl Workspace { render_notify: Arc, render_dirty: Arc, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_with_extra_env( + Self::new_with_tab( initial_cwd, rows, cols, @@ -300,6 +295,7 @@ impl Workspace { events, render_notify, render_dirty, + None, Vec::new(), ) } @@ -318,6 +314,20 @@ impl Workspace { render_dirty: Arc, extra_env: Vec<(String, String)>, ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { + if extra_env.is_empty() { + return Self::new( + initial_cwd, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + host_terminal_appearance, + shell_config, + events, + render_notify, + render_dirty, + ); + } Self::new_with_tab( initial_cwd, rows, @@ -334,65 +344,6 @@ impl Workspace { ) } - // Kept for tests that do not need launch-env customization. - #[allow(dead_code)] - pub fn new_argv_command( - initial_cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_argv_command_with_extra_env( - initial_cwd, - rows, - cols, - argv, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - events, - render_notify, - render_dirty, - Vec::new(), - ) - } - - #[allow(clippy::too_many_arguments)] - pub fn new_argv_command_with_extra_env( - initial_cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - extra_env: Vec<(String, String)>, - ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> { - Self::new_with_tab( - initial_cwd, - rows, - cols, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), - events, - render_notify, - render_dirty, - Some(argv), - extra_env, - ) - } - #[allow(clippy::too_many_arguments)] fn new_with_tab( initial_cwd: PathBuf, @@ -671,43 +622,6 @@ impl Workspace { self.close_tab(self.active_tab) } - #[cfg(test)] - pub fn split_focused( - &mut self, - direction: Direction, - rows: u16, - cols: u16, - cwd: Option, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - shell_config: crate::pane::PaneShellConfig<'_>, - extra_env: Vec<(String, String)>, - ) -> std::io::Result { - let pane_number = self.next_public_pane_number; - let tab_number = self - .active_tab() - .map(|tab| tab.number) - .expect("workspace must always have at least one tab"); - let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); - let new_pane = self - .active_tab_mut() - .expect("workspace must always have at least one tab") - .split_focused( - direction, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - &launch_env, - )?; - self.register_new_pane_with_number(new_pane.pane_id, pane_number); - Ok(new_pane) - } - #[allow(clippy::too_many_arguments)] pub fn split_focused_command( &mut self, @@ -1174,14 +1088,6 @@ impl Workspace { self.worktree_space.as_ref() } - #[cfg(test)] - pub fn refresh_git_ahead_behind(&mut self) { - let cwd = self.resolved_identity_cwd(); - self.cached_git_branch = cwd.as_deref().and_then(git_branch); - self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind); - self.cached_git_space = cwd.as_deref().and_then(git_space_metadata); - } - pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option { self.tabs .iter() @@ -1663,50 +1569,6 @@ mod tests { assert!(!target.tabs[0].panes.contains_key(&source_pane)); } - #[tokio::test] - async fn new_workspace_retains_discovered_git_metadata() { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "herdr-workspace-git-metadata-{}-{stamp}", - std::process::id() - )); - std::fs::create_dir_all(root.join(".git")).expect("create git directory"); - std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write git head"); - #[cfg(windows)] - let command = "C:\\Windows\\System32\\whoami.exe"; - #[cfg(not(windows))] - let command = "/usr/bin/true"; - let argv = vec![command.to_string()]; - let (events, _) = mpsc::channel(64); - let render_notify = Arc::new(Notify::new()); - let render_dirty = Arc::new(RenderSignal::new()); - - let (workspace, _terminal, runtime) = Workspace::new_argv_command( - root.clone(), - 24, - 80, - &argv, - 1024, - crate::terminal_theme::TerminalTheme::default(), - None, - events, - render_notify, - render_dirty, - ) - .expect("create workspace"); - - let space = workspace - .git_space() - .expect("workspace should retain discovered git metadata"); - assert_eq!(space.repo_root, root); - - runtime.shutdown(); - std::fs::remove_dir_all(root).expect("remove test repo"); - } - #[test] fn linked_worktree_auto_label_uses_checkout_name_not_repo_name() { let (base, repo, checkout) = diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 29621d4d..3fd45fca 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::detect::{Agent, AgentState}; +use crate::detect::AgentState; use crate::layout::PaneId; use crate::terminal::{TerminalId, TerminalState}; @@ -10,18 +10,10 @@ use super::{Tab, Workspace}; pub struct PaneDetail { pub pane_id: PaneId, pub tab_idx: usize, - pub tab_label: String, - pub label: String, - pub pane_label: Option, - pub terminal_title: Option, - pub terminal_title_stripped: Option, - pub agent_label: String, pub agent_kind_label: Option, - pub agent: Option, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option, - pub state_labels: HashMap, pub tokens: HashMap, } @@ -30,7 +22,6 @@ impl Tab { &self, terminals: &HashMap, tab_idx: usize, - tab_label: &str, ) -> Vec { self.layout .pane_ids() @@ -39,32 +30,16 @@ impl Tab { let pane = self.panes.get(id)?; let terminal = terminals.get(&pane.attached_terminal_id)?; let agent_kind_label = terminal.effective_agent_label().map(str::to_string); - let fallback_agent_label = terminal - .agent_name - .as_deref() - .or(agent_kind_label.as_deref())? - .to_string(); - let agent_label = terminal - .effective_display_agent() - .unwrap_or_else(|| fallback_agent_label.clone()); - let presentation = terminal.effective_presentation(); + if terminal.agent_name.is_none() && agent_kind_label.is_none() { + return None; + } Some(PaneDetail { pane_id: *id, tab_idx, - tab_label: tab_label.to_string(), - label: agent_label.clone(), - pane_label: terminal - .effective_title() - .or_else(|| terminal.manual_label.clone()), - terminal_title: terminal.terminal_title.clone(), - terminal_title_stripped: terminal.terminal_title_stripped(), - agent_label, agent_kind_label, - agent: terminal.effective_known_agent(), state: terminal.state, seen: pane.seen, last_agent_state_change_seq: terminal.last_agent_state_change_seq, - state_labels: presentation.state_labels, tokens: terminal.metadata_tokens.values(), }) }) @@ -100,22 +75,10 @@ impl Workspace { } pub fn pane_details(&self, terminals: &HashMap) -> Vec { - let multi_tab = self.tabs.len() > 1; self.tabs .iter() .enumerate() - .flat_map(|(tab_idx, tab)| { - let tab_label = self - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - tab.pane_details(terminals, tab_idx, &tab_label).into_iter() - }) - .map(|mut detail| { - if multi_tab { - detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); - } - detail - }) + .flat_map(|(tab_idx, tab)| tab.pane_details(terminals, tab_idx)) .collect() } } @@ -193,70 +156,6 @@ mod tests { assert!(!seen); } - #[test] - fn pane_details_prefers_agent_name_over_detected_agent_label() { - let ws = Workspace::test_new("test"); - let root_pane = ws.tabs[0].root_pane; - let mut terminals = HashMap::new(); - let mut terminal = terminal_for_pane(&ws, root_pane); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_agent_name("planner".into()); - terminals.insert(terminal.id.clone(), terminal); - - let labels: Vec<_> = ws - .pane_details(&terminals) - .into_iter() - .map(|detail| (detail.label, detail.agent_label, detail.agent)) - .collect(); - - assert_eq!( - labels, - vec![("planner".into(), "planner".into(), Some(Agent::Pi))] - ); - } - - #[test] - fn pane_details_includes_tab_context_for_multi_tab_workspace() { - let mut ws = Workspace::test_new("test"); - ws.tabs[0].custom_name = Some("main".into()); - let root_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("review")); - let review_pane = ws.tabs[second_tab].root_pane; - let mut terminals = HashMap::new(); - let mut root_terminal = terminal_for_pane(&ws, root_pane); - root_terminal.set_hook_authority( - "test".into(), - "pi".into(), - AgentState::Working, - None, - None, - ); - terminals.insert(root_terminal.id.clone(), root_terminal); - let mut review_terminal = terminal_for_pane(&ws, review_pane); - review_terminal.set_hook_authority( - "test".into(), - "claude".into(), - AgentState::Idle, - None, - None, - ); - terminals.insert(review_terminal.id.clone(), review_terminal); - - let labels: Vec<_> = ws - .pane_details(&terminals) - .into_iter() - .map(|detail| (detail.label, detail.agent_label, detail.agent)) - .collect(); - - assert_eq!( - labels, - vec![ - ("main·pi".into(), "pi".into(), Some(Agent::Pi)), - ("review·claude".into(), "claude".into(), Some(Agent::Claude)), - ] - ); - } - #[test] fn pane_details_use_tab_vector_index_not_stable_public_tab_number() { let mut ws = Workspace::test_new("test"); diff --git a/src/workspace/git/mod.rs b/src/workspace/git/mod.rs index b00cf3b3..b17cd347 100644 --- a/src/workspace/git/mod.rs +++ b/src/workspace/git/mod.rs @@ -18,6 +18,3 @@ pub use self::{ git_status_snapshot_for_cwd_with_demand, GitStatusCacheEntry, GitStatusRefreshDemand, }, }; - -#[cfg(test)] -pub(super) use self::status::git_ahead_behind; diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs index 9f40170c..1c29d75c 100644 --- a/src/workspace/git/status.rs +++ b/src/workspace/git/status.rs @@ -314,25 +314,6 @@ fn read_upstream(repo: &mut RepoContext, branch: &str) -> Option Option<(usize, usize)> { - super::discovery::git_repo_root(cwd)?; - - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - parse_git_ahead_behind_output(&stdout) -} - fn git_ahead_behind_between( cwd: &Path, head_oid: &str, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 6f0fa75e..79d7f94d 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -205,36 +205,6 @@ impl Tab { self.custom_name = Some(name); } - #[cfg(test)] - pub fn split_focused( - &mut self, - direction: Direction, - rows: u16, - cols: u16, - cwd: Option, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - shell_config: crate::pane::PaneShellConfig<'_>, - launch_env: &PaneLaunchEnv, - ) -> std::io::Result { - self.split_pane_with_runtime( - self.layout.focused(), - true, - direction, - None, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - launch_env, - None, - ) - } - pub fn split_focused_command( &mut self, direction: Direction, diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 0a52e6a7..b8823ac4 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{Read, Write}; diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs index 94ab3f07..49473dbb 100644 --- a/tests/auto_detect.rs +++ b/tests/auto_detect.rs @@ -2,7 +2,7 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -150,7 +150,7 @@ fn spawn_herdr_auto( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - // No subcommand, no --no-session → auto-detect launch + // No subcommand → auto-detect launch. cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); @@ -168,48 +168,6 @@ fn spawn_herdr_auto( } } -/// Spawn `herdr --no-session` — the monolithic escape hatch. -fn spawn_herdr_no_session( - config_home: &Path, - runtime_dir: &Path, - api_socket_path: &Path, -) -> SpawnedHerdr { - fs::create_dir_all(config_home.join("herdr")).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join("herdr/config.toml"), - "onboarding = false\n", - ) - .unwrap(); - - let pair = native_pty_system() - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .unwrap(); - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - cmd.arg("--no-session"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", api_socket_path); - cmd.env("SHELL", "/bin/sh"); - cmd.env_remove("HERDR_ENV"); - - let child = pair.slave.spawn_command(cmd).unwrap(); - register_spawned_herdr_pid(child.process_id()); - drop(pair.slave); - - SpawnedHerdr { - _master: pair.master, - child, - } -} - fn ping_socket(socket_path: &Path) -> String { let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); @@ -423,50 +381,6 @@ fn auto_detect_socket_path_consistency() { cleanup_spawned_herdr(herdr, base); } -/// `herdr --no-session` bypasses server/client and runs -/// monolithically. No server process is spawned. No client socket is created. -#[test] -fn no_session_flag_runs_monolithically() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - // Run `herdr --no-session` — monolithic mode, no server/client. - let herdr = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket); - - // Wait for the API socket (monolithic mode creates it). - wait_for_socket(&api_socket, Duration::from_secs(10)); - - // Verify the API socket exists and responds. - let response = ping_socket(&api_socket); - assert!( - response.contains("pong"), - "monolithic API should respond: {response}" - ); - - // Verify NO client socket was created — this is the key distinction - // between monolithic mode and server/client mode. - assert!( - !client_socket.exists(), - "no client socket should exist in monolithic mode" - ); - - // Verify the API socket is served by the monolithic process itself, - // not by a separate server. We can check this by verifying the client - // PID matches what would be serving the socket — in monolithic mode, - // there is only one herdr process. - let client_pid = herdr.child.process_id().expect("should have PID"); - assert!( - process_exists(client_pid), - "monolithic process should be running" - ); - - cleanup_spawned_herdr(herdr, base); -} - /// CLI subcommands work through the server's JSON API socket. #[test] fn cli_subcommands_work_through_server() { @@ -700,34 +614,6 @@ fn auto_detect_writes_client_and_server_logs_to_separate_files() { cleanup_spawned_herdr(spawned, base); } -#[test] -fn no_session_writes_startup_logs_to_monolith_file() { - 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_herdr_no_session(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - - let app_dir_name = if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - }; - let log_dir = config_home.join(app_dir_name); - let monolith_log = log_dir.join("herdr.log"); - - wait_for_log_contains( - &monolith_log, - "event=\"app.startup\" subsystem=\"app\"", - Duration::from_secs(10), - ); - - cleanup_spawned_herdr(spawned, base); -} - #[test] fn auto_detect_respects_nested_guard_before_auto_attach() { let _lock = test_lock(); diff --git a/tests/cli.rs b/tests/cli.rs index c7eb2834..ff238996 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ #![cfg(not(target_os = "macos"))] -mod support; +pub mod support; #[path = "cli/mod.rs"] mod cases; diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 5112182d..c6c76c34 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -2,7 +2,7 @@ #![cfg(unix)] -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,12 +13,13 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::Value; use support::{ - cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message, - register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, - wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir, + register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, + wait_for_message_variant, wait_for_message_variants, wait_for_socket, wait_until, + CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, + SERVER_MESSAGE_SEMANTIC_NOTIFICATION, SERVER_MESSAGE_SERVER_SHUTDOWN, }; fn unique_test_dir() -> PathBuf { @@ -86,11 +87,29 @@ fn spawn_client_process( spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"]) } +fn spawn_client_shell_process( + config_home: &PathBuf, + runtime_dir: &PathBuf, + api_socket_path: &PathBuf, +) -> SpawnedHerdr { + spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"]) +} + fn spawn_client_process_with_args( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, args: &[&str], +) -> SpawnedHerdr { + spawn_client_process_with_args_and_env(config_home, runtime_dir, api_socket_path, args, &[]) +} + +fn spawn_client_process_with_args_and_env( + config_home: &PathBuf, + runtime_dir: &PathBuf, + api_socket_path: &PathBuf, + args: &[&str], + extra_env: &[(&str, &str)], ) -> SpawnedHerdr { register_runtime_dir(runtime_dir); let pair = native_pty_system() @@ -111,47 +130,10 @@ fn spawn_client_process_with_args( cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); - - let child = pair.slave.spawn_command(cmd).unwrap(); - register_spawned_herdr_pid(child.process_id()); - drop(pair.slave); - - SpawnedHerdr { - _master: Some(pair.master), - child, + for (key, value) in extra_env { + cmd.env(key, value); } -} -fn spawn_no_session_process(config_home: &PathBuf, runtime_dir: &PathBuf) -> SpawnedHerdr { - fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join(app_dir_name()).join("config.toml"), - "onboarding = false\n[ui]\nwindow_title = \"monolithic\"\n", - ) - .unwrap(); - - let pair = native_pty_system() - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .unwrap(); - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - cmd.arg("--no-session"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("SHELL", "/bin/sh"); - cmd.env_remove("HERDR_ENV"); - cmd.env_remove("HERDR_SOCKET_PATH"); - cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); - cmd.env_remove("HERDR_SESSION"); - cmd.env_remove("HERDR_WORKSPACE_ID"); - cmd.env_remove("HERDR_TAB_ID"); - cmd.env_remove("HERDR_PANE_ID"); let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); @@ -266,100 +248,12 @@ fn app_dir_name() -> &'static str { } } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> std::io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn read_next_frame_payload(stream: &mut UnixStream, timeout: Duration) -> Result, String> { - stream - .set_read_timeout(Some(Duration::from_millis(200))) - .map_err(|e| e.to_string())?; - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - match read_server_message(stream) { - Ok((1, payload)) => return Ok(payload), - Ok(_) => continue, - Err(_) => continue, - } - } - Err("timed out waiting for Frame message".into()) -} - -fn frame_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - let width = frame.width.max(1) as usize; - let mut text = String::new(); - for row in frame.cells.chunks(width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - text.push_str(&cell.symbol); - } - text.push('\n'); - } - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - text -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] -fn client_connects_and_receives_frame() { - // Client connects to server and handshake completes. - // Client receives Frame messages. - // Server sends rendered frames to connected clients. +fn client_connects_and_receives_pane_surface() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -371,22 +265,13 @@ fn client_connects_and_receives_frame() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!( - version, CURRENT_PROTOCOL, - "server should report current protocol version" - ); - assert!( - error.is_none(), - "handshake should not have error: {:?}", - error - ); - - read_next_frame_payload(&mut stream, Duration::from_secs(10)) - .expect("should receive a frame from server"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("should receive the shell snapshot and pane surface"); cleanup_spawned_herdr(spawned, base); } @@ -560,41 +445,26 @@ fn client_sees_headless_startup_config_diagnostic() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - let mut found_diagnostic = false; - let mut last_frame_text = String::new(); - while Instant::now() < deadline { - match read_server_message(&mut stream) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload).expect("decode frame"); - last_frame_text = frame_text(&frame); - if last_frame_text.contains("config.toml") - && last_frame_text.contains("herdr config check") - { - found_diagnostic = true; - break; - } - } - Ok(_) => {} - Err(_) => break, - } - } - + let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output = spawn_pty_drain( + client + ._master + .as_ref() + .expect("client shell master") + .try_clone_reader() + .expect("clone client shell reader"), + ); assert!( - found_diagnostic, - "attached client should see startup config parse diagnostic; last frame:\n{last_frame_text}" + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output); + output.contains("config.toml") && output.contains("herdr config check") + }), + "client shell should render startup config diagnostic; output: {:?}", + read_output(&output) ); - cleanup_spawned_herdr(spawned, base); + drop(spawned); + cleanup_spawned_herdr(client, base); } #[test] @@ -881,6 +751,120 @@ fn attach_thin_client_with_config( (spawned_server, thin_client, output) } +#[test] +fn client_shell_detaches_restores_and_freshly_reattaches_to_current_state() { + 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 client_socket = runtime_dir.join("herdr-client.sock"); + + let mut server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + wait_for_socket(&client_socket, Duration::from_secs(10)); + + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "client-shell-lifecycle-workspace", + "method": "workspace.create", + "params": {"cwd": base, "focus": true, "label": "shell-lifecycle"}, + }) + .to_string(), + ); + assert_eq!(created["result"]["type"], "workspace_created", "{created}"); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + send_pane_shell_command(&api_socket, &pane_id, "printf 'SHELL_LIFECYCLE_INITIAL\\n'"); + + let mut client_a = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output_a = spawn_pty_drain( + client_a + ._master + .as_ref() + .expect("first client shell PTY") + .try_clone_reader() + .expect("clone first client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output_a); + output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_INITIAL") + }), + "client shell should compose one coherent snapshot and pane surface; output: {:?}", + read_output(&output_a) + ); + + let detach_watermark = output_len(&output_a); + client_a + ._master + .as_ref() + .expect("first client shell PTY") + .take_writer() + .expect("first client shell writer") + .write_all(b"\x02q") + .expect("detach first client shell"); + let detach_output = drain_until_client_exits(&mut client_a, &output_a, detach_watermark); + assert!( + output_has_mouse_teardown(&detach_output), + "client shell should restore the host terminal after detach; output: {detach_output:?}" + ); + assert!( + ping_socket(&api_socket).contains("pong"), + "server should remain alive after client shell detach" + ); + drop(client_a); + + send_pane_shell_command( + &api_socket, + &pane_id, + "printf 'SHELL_LIFECYCLE_DETACHED\\n'", + ); + let mut client_b = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output_b = spawn_pty_drain( + client_b + ._master + .as_ref() + .expect("reattached client shell PTY") + .try_clone_reader() + .expect("clone reattached client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + let output = read_output(&output_b); + output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_DETACHED") + }), + "fresh client shell should receive current state and detached-period output; output: {:?}", + read_output(&output_b) + ); + + let disconnect_watermark = output_len(&output_b); + if let Some(pid) = server.child.process_id() { + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + } + server.close_master(); + let disconnect_output = + drain_until_client_exits(&mut client_b, &output_b, disconnect_watermark); + assert!( + output_has_mouse_teardown(&disconnect_output), + "client shell should restore the host terminal after endpoint loss; output: {disconnect_output:?}" + ); + assert!( + disconnect_output + .to_lowercase() + .contains("lost connection to server"), + "client shell should explain endpoint loss; output: {disconnect_output:?}" + ); + + drop(server); + cleanup_spawned_herdr(client_b, base); +} + fn captured_window_titles(output: &SharedOutput) -> Vec { read_output(output) .split("\x1b]0;") @@ -942,26 +926,6 @@ fn send_pane_shell_command(socket_path: &PathBuf, pane_id: &str, command: &str) assert_eq!(response["result"]["type"], "ok", "{response}"); } -#[test] -fn configured_window_title_is_emitted_in_no_session_mode() { - let _lock = test_lock(); - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let no_session = spawn_no_session_process(&config_home, &runtime_dir); - let reader = no_session - ._master - .as_ref() - .expect("no-session PTY master") - .try_clone_reader() - .expect("clone no-session PTY reader"); - let output = spawn_pty_drain(reader); - - wait_for_window_title(&output, "monolithic"); - - cleanup_spawned_herdr(no_session, base); -} - #[test] fn configured_window_title_tracks_all_tokens_and_focused_osc_only() { let _lock = test_lock(); @@ -1029,7 +993,7 @@ fn configured_window_title_tracks_all_tokens_and_focused_osc_only() { "hostname token was empty: {renamed}" ); - send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;⠋ building\007'"); + send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;building\007'"); wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=building"); let second_tab = send_json_request( @@ -1308,14 +1272,7 @@ fn client_exits_cleanly_when_terminal_hangs_up() { } #[test] -fn client_receives_frame_after_pane_output() { - // End-to-end test: server renders, client receives Frame. - // This test verifies the full flow: - // 1. Start server - // 2. Connect client, handshake - // 3. Send input to pane (echo command) - // 4. Wait for a new frame from the server - // 5. Verify the frame contains the pane output +fn client_receives_pane_surface_after_pane_output() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1327,33 +1284,55 @@ fn client_receives_frame_after_pane_output() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10)) + .expect("initial client shell bootstrap"); - read_next_frame_payload(&mut stream, Duration::from_secs(10)) - .expect("should receive initial frame"); + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "create-output-workspace", + "method": "workspace.create", + "params": {"label": "output", "focus": true} + }) + .to_string(), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id"); + assert!(wait_for_message_variant( + &mut stream, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE, + ) + .expect("wait for created workspace surface")); - // Send input to trigger a state change and re-render. - let input_data = b"echo test-output\n".to_vec(); - let input_payload = { - let mut buf = encode_varint_u32(1); // Input variant - buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32)); - buf.extend_from_slice(&input_data); - buf - }; - let framed = frame_message(&input_payload); - stream.write_all(&framed).expect("send input"); - stream.flush().expect("flush"); - - // Read subsequent frames — the server should have re-rendered after - // the input was processed. - let received_frame = wait_for_message_variant(&mut stream, Duration::from_secs(2), 1) - .expect("wait for post-output frame"); - assert!(received_frame, "should receive a Frame after pane output"); + let sent = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "send-output", + "method": "pane.send_text", + "params": {"pane_id": pane_id, "text": "printf 'test-output\\n'\\n"} + }) + .to_string(), + ); + assert!(sent.get("error").is_none(), "{sent}"); + assert!( + wait_for_message_variants( + &mut stream, + Duration::from_secs(5), + &[ + SERVER_MESSAGE_PANE_SURFACE, + SERVER_MESSAGE_PANE_SURFACE_PATCH, + ], + ) + .expect("wait for post-output pane surface"), + "should receive a pane surface update after pane output" + ); cleanup_spawned_herdr(spawned, base); } @@ -1424,7 +1403,25 @@ fn pane_spawn_cwd_fallback_in_server() { "fallback cwd should exist: {cwd}" ); - cleanup_spawned_herdr(spawned, base); + let client_shell = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let output = spawn_pty_drain( + client_shell + ._master + .as_ref() + .expect("restored client shell PTY") + .try_clone_reader() + .expect("clone restored client shell reader"), + ); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + read_output(&output).contains("missing-cwd") + }), + "client shell should render the restored session; output: {:?}", + read_output(&output) + ); + + drop(spawned); + cleanup_spawned_herdr(client_shell, base); } #[test] @@ -1442,18 +1439,13 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frame(s). - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - while read_server_message(&mut stream).is_ok() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Send SIGINT to the server process to trigger graceful shutdown. if let Some(pid) = spawned.child.process_id() { @@ -1462,7 +1454,7 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { } } - // The client should receive a ServerShutdown message (variant 4) + // The client should receive a ServerShutdown message // before the connection is closed, not just an abrupt EOF. stream .set_read_timeout(Some(Duration::from_secs(5))) @@ -1471,8 +1463,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { match result { Ok((variant, _payload)) => { assert_eq!( - variant, 4, - "expected ServerShutdown (variant 4), got variant {variant}" + variant, SERVER_MESSAGE_SERVER_SHUTDOWN, + "expected ServerShutdown, got variant {variant}" ); } Err(e) => { @@ -1501,9 +1493,9 @@ fn client_receives_notify_on_agent_state_change() { let client_socket = runtime_dir.join("herdr-client.sock"); // Enable toast and sound in config so the server produces notifications. - fs::create_dir_all(config_home.join("herdr")).unwrap(); + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); fs::write( - config_home.join("herdr/config.toml"), + config_home.join(app_dir_name()).join("config.toml"), "onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n", ) .unwrap(); @@ -1541,18 +1533,13 @@ fn client_receives_notify_on_agent_state_change() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); - // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frame(s). - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - while read_server_message(&mut stream).is_ok() {} + assert!(error.is_none(), "{error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Create a workspace via the API. let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -1596,8 +1583,7 @@ fn client_receives_notify_on_agent_state_change() { let mut report_response = String::new(); report_reader.read_line(&mut report_response).unwrap(); - // Read messages from the client stream and look for Notify (variant 5). - // Notify = ServerMessage variant index 5. + // Read messages from the client stream and look for the semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1606,12 +1592,11 @@ fn client_receives_notify_on_agent_state_change() { while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { - if variant == 5 { - // ServerMessage::Notify — found it! + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_notify = true; break; } - // Continue reading — Frame messages (variant 1) will come first. + // Snapshot and pane-surface messages may arrive first. } Err(_) => { break; @@ -1621,7 +1606,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_notify, - "client should receive a ServerMessage::Notify after pane.report_agent" + "client should receive a semantic notification after pane.report_agent" ); // Now report Idle from Working — this should trigger a Done sound @@ -1683,7 +1668,7 @@ fn client_receives_notify_on_agent_state_change() { let mut idle_response = String::new(); idle_reader.read_line(&mut idle_response).unwrap(); - // Read messages and look for Done sound notify. + // Read messages and look for the done semantic notification. stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); @@ -1692,16 +1677,14 @@ fn client_receives_notify_on_agent_state_change() { while Instant::now() < deadline { match read_server_message(&mut stream) { Ok((variant, _payload)) => { - if variant == 5 { - // Found a Notify message — that's good enough. - // The test already verified the Blocked→Notify path above. + if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION { found_done_notify = true; break; } - // Continue reading — Frame messages will come first. + // Snapshot and pane-surface messages may arrive first. } Err(e) => { - eprintln!("read error while looking for Done Notify: {e}"); + eprintln!("read error while looking for done notification: {e}"); break; } } @@ -1709,7 +1692,7 @@ fn client_receives_notify_on_agent_state_change() { assert!( found_done_notify, - "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle" + "client should receive a semantic notification when a background pane transitions Working→Idle" ); cleanup_spawned_herdr(spawned, base); diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 6f1ab82b..9b856cb8 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -1,6 +1,6 @@ //! Cross-area integration tests for end-to-end persistence flows. -mod support; +pub mod support; use std::fs; use std::io::{self, BufRead, BufReader, Read, Write}; @@ -11,11 +11,11 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::{json, Value}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, }; fn unique_test_dir() -> PathBuf { @@ -196,7 +196,7 @@ fn workspace_create(socket_path: &Path, label: &str) -> Value { socket_path, "workspace_create", "workspace.create", - json!({ "label": label }), + json!({ "label": label, "focus": true }), ) } @@ -365,16 +365,6 @@ fn encode_varint_u32(v: u32) -> Vec { } } -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - fn frame_message(payload: &[u8]) -> Vec { let mut framed = (payload.len() as u32).to_le_bytes().to_vec(); framed.extend_from_slice(payload); @@ -414,76 +404,6 @@ fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), Stri } } -fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("set read timeout"); - - // ClientMessage::Hello = variant 0 - let mut payload = encode_varint_u32(0); - payload.extend_from_slice(&encode_varint_u32(version)); - payload.extend_from_slice(&encode_varint_u16(cols)); - payload.extend_from_slice(&encode_varint_u16(rows)); - payload.extend_from_slice(&encode_varint_u32(8)); // cell_width_px - payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px - payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame - payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server - payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App - - stream - .write_all(&frame_message(&payload)) - .expect("write hello"); - stream.flush().expect("flush hello"); - - let mut len_buf = [0u8; 4]; - stream - .read_exact(&mut len_buf) - .expect("read welcome length"); - let len = u32::from_le_bytes(len_buf) as usize; - assert!(len > 0 && len <= 2 * 1024 * 1024, "unexpected welcome size"); - - let mut welcome_payload = vec![0u8; len]; - stream - .read_exact(&mut welcome_payload) - .expect("read welcome payload"); - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&welcome_payload, offset).expect("decode variant"); - offset += consumed; - assert_eq!(variant, 0, "expected ServerMessage::Welcome variant"); - - let (_server_version, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode version"); - offset += consumed; - - let (_encoding, consumed) = - decode_varint_u32(&welcome_payload, offset).expect("decode render encoding"); - offset += consumed; - - let option_tag = *welcome_payload - .get(offset) - .expect("welcome payload should contain Option tag"); - if option_tag == 1 { - let (str_len, consumed) = - decode_varint_u32(&welcome_payload, offset + 1).expect("decode error length"); - let start = offset + 1 + consumed; - let end = start + str_len as usize; - let err = String::from_utf8(welcome_payload[start..end].to_vec()).expect("utf8 error"); - panic!("handshake rejected: {err}"); - } -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let mut payload = encode_varint_u32(1); - payload.extend_from_slice(&encode_varint_u32(data.len() as u32)); - payload.extend_from_slice(data); - stream - .write_all(&frame_message(&payload)) - .expect("write input"); - stream.flush().expect("flush input"); -} - fn send_client_detach(stream: &mut UnixStream) { // ClientMessage::Detach = variant 4 let payload = encode_varint_u32(4); @@ -500,88 +420,8 @@ fn is_timeout(err: &io::Error) -> bool { ) } -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn frame_contains_colored_symbol(frame: &FrameWire, symbol: &str, rgb: (u8, u8, u8)) -> bool { - let (r, g, b) = rgb; - let fg = 0x02_00_00_00 | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); - frame - .cells - .iter() - .any(|cell| cell.symbol == symbol && cell.fg == fg) -} - -fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { - if frame.cells.is_empty() { - return false; - } - - let width = frame.width.max(1) as usize; - let mut text = String::new(); - for row in frame.cells.chunks(width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - text.push_str(&cell.symbol); - } - text.push('\n'); - } - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - text.contains(needle) -} - fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result { stream.set_read_timeout(Some(timeout))?; - let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf)?; let len = u32::from_le_bytes(len_buf) as usize; @@ -591,73 +431,20 @@ fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result "zero-length payload", )); } - let mut payload = vec![0u8; len]; stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec)> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok((variant, payload[consumed..].to_vec())) -} - -fn wait_for_frame_matching( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, payload)) => { - let frame = decode_frame_payload(&payload)?; - if predicate(&frame) { - return Ok(true); - } - } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok(false) + decode_varint_u32(&payload, 0) + .map(|(variant, _)| variant) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); + let slice = deadline.saturating_duration_since(Instant::now()); match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame + Ok(SERVER_MESSAGE_PANE_SURFACE | SERVER_MESSAGE_PANE_SURFACE_PATCH) => return true, + Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {} Ok(_) => {} Err(err) if is_timeout(&err) => {} Err(_) => return false, @@ -669,7 +456,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { let deadline = Instant::now() + max_drain; while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(40)) { + match read_server_variant(stream, deadline.saturating_duration_since(Instant::now())) { Ok(_) => {} Err(err) if is_timeout(&err) => break, Err(_) => break, @@ -696,7 +483,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -733,7 +520,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -789,7 +576,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -840,18 +627,10 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "agent status should remain working while detached" ); - // Reattach and ensure client-side state reflects the persisted working status. + // Reattach and verify the persisted projection through the API. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24); - let saw_working_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (249, 226, 175)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_working_on_client, - "reattached client frame should expose persisted agent working status" - ); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake"); + assert!(wait_for_frame(&mut client_b, Duration::from_secs(5))); // Transition to blocked and verify API + client surfaces both observe it. // The fake process remains visibly working, so blocked is the deterministic @@ -862,15 +641,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { "pane agent status should transition to blocked" ); - let saw_blocked_on_client = - wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { - frame_contains_colored_symbol(frame, "●", (243, 139, 168)) - }) - .expect("frame decoding should succeed"); - assert!( - saw_blocked_on_client, - "reattached client frame should show blocked status after transition" - ); + // The API status above is the stable cross-area contract for this transition. cleanup_spawned_herdr(server, base); } @@ -889,7 +660,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -902,17 +673,8 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { .expect("workspace.create should return workspace_id") .to_string(); - // The attached client must receive a frame that includes the new workspace - // label, proving client-side state reflects the API surface. - let saw_workspace_on_client = - wait_for_frame_matching(&mut client, Duration::from_secs(3), |frame| { - frame_contains_text(frame, "api-visible-workspace") - }) - .expect("frame decoding should succeed"); - assert!( - saw_workspace_on_client, - "client-side frame should include the newly created workspace label" - ); + // ClientShell state is asserted through the authoritative API projection below; + // do not decode client-composed UI to duplicate that assertion. let deadline = Instant::now() + Duration::from_secs(5); let mut count_reached = false; @@ -952,9 +714,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30); + client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30).expect("shell handshake"); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30); + client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30).expect("shell handshake"); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -968,7 +730,7 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { .to_string(); // Input from client A should update shared state visible to client B. - send_client_input(&mut client_a, b"echo SHARED_VIEW\n"); + pane_send_text(&api_socket, &pane_id, "echo SHARED_VIEW\n"); assert!( wait_for_frame(&mut client_b, Duration::from_secs(2)), "client B should receive update from client A" @@ -982,19 +744,24 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { // Detach client A; client B should keep working. send_client_detach(&mut client_a); + assert!( + support::wait_for_disconnect(&mut client_a, Duration::from_secs(2)) + .expect("wait for client A detach"), + "client A should disconnect before the remaining-client assertion" + ); drop(client_a); - send_client_input(&mut client_b, b"echo AFTER_A_DETACH\n"); - assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "remaining client should still receive frames after other client detaches" - ); + pane_send_text(&api_socket, &pane_id, "echo AFTER_A_DETACH\n"); assert!(pane_read_recent_contains( &api_socket, &pane_id, "AFTER_A_DETACH", Duration::from_secs(5) )); + assert!( + wait_for_frame(&mut client_b, Duration::from_secs(2)), + "remaining client should still receive frames after other client detaches" + ); let ping = ping_socket(&api_socket); assert!( @@ -1123,7 +890,8 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24); + client_shell_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24) + .expect("shell handshake"); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index 4461a17b..f570e979 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -1,7 +1,7 @@ //! Integration tests for detach/reattach flow. //! -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; @@ -14,9 +14,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use serde_json::Value; use support::{ - cleanup_test_base, client_handshake, drain_messages, read_server_message, register_runtime_dir, - register_spawned_herdr_pid, send_detach, send_input, unregister_spawned_herdr_pid, - wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect, + wait_for_socket, wait_until, CURRENT_PROTOCOL, }; const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false @@ -279,72 +279,6 @@ fn first_pane_id(response: &Value) -> String { // Tests // --------------------------------------------------------------------------- -#[test] -fn navigate_q_detaches_client_and_server_persists() { - // In persistence mode, navigate-mode q detaches the client and the server persists. - // Flow: - // 1. Start server - // 2. Connect client, handshake - // 3. Send prefix key (Ctrl+B) then 'q' - // 4. Verify server is still alive via API ping - // 5. Verify the client connection is closed - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_socket(&client_socket, Duration::from_secs(10)); - - // Connect and handshake. - let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); - assert_eq!(version, CURRENT_PROTOCOL); - assert!(error.is_none(), "{:?}", error); - - // Drain initial frames. - drain_messages(&mut stream); - - // Send prefix key (Ctrl+B = 0x02) then 'q' (quit/detach in persistence mode). - send_input(&mut stream, &[0x02]).expect("send prefix"); - - // Drain any frames generated by entering navigate mode. - drain_messages(&mut stream); - - send_input(&mut stream, b"q").expect("send detach key"); - - assert!( - wait_until(Duration::from_secs(2), Duration::from_millis(25), || { - ping_socket(&api_socket).contains("pong") - }), - "server should still respond to ping after client detach" - ); - - // Verify server is still alive and responsive. - let response = ping_socket(&api_socket); - assert!( - response.contains("pong"), - "server should still respond to ping after client detach: {response}" - ); - - // The client should receive a ServerShutdown with reason "detached" - // shortly after the quit/detach key. There may be some frames in - // between from the mode change, so we read multiple messages. - let got_shutdown = wait_for_message_variant(&mut stream, Duration::from_secs(2), 2) - .expect("wait for shutdown message") - || wait_for_disconnect(&mut stream, Duration::from_secs(1)).expect("wait for disconnect"); - assert!( - got_shutdown, - "client should receive ServerShutdown after quit/detach key" - ); - - cleanup_spawned_herdr(spawned, base); -} - #[test] fn explicit_detach_message_causes_clean_disconnect() { // Client sends ClientMessage::Detach @@ -363,8 +297,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -422,7 +356,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -461,7 +395,7 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!( @@ -470,31 +404,10 @@ fn reattach_after_detach_shows_current_state() { error ); - // Client B should receive a frame with the current state, + // Client B receives the client-owned shell projection. // including the workspace created while client A was attached. - stream_b.set_nonblocking(false).unwrap(); - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _payload)) => { - if variant == 1 { - // ServerMessage::Frame - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame with current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); // Verify the workspace still exists via API. let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API"); @@ -541,19 +454,29 @@ fn processes_survive_during_and_after_detach() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); - // Send input to the pane — the fresh server should have at least one - // pane with a shell running. - send_input(&mut stream, b"echo SURVIVED_DETACH\n").expect("send echo command"); + // Drive the pane through the JSON API; the shell transport carries only + // client-composed UI state, not legacy raw shell input. + let created = workspace_create(&api_socket, "process-survival"); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("root pane id") + .to_string(); + pane_send_text(&api_socket, &pane_id, "echo SURVIVED_DETACH\n"); + assert!(wait_until( + Duration::from_secs(5), + Duration::from_millis(50), + || pane_read_recent_text(&api_socket, &pane_id).contains("SURVIVED_DETACH") + )); - // Drain any frames generated by the input. + // Drain any shell projection updates. drain_messages(&mut stream); // Detach the client via explicit Detach message. @@ -580,32 +503,14 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } @@ -629,8 +534,8 @@ fn server_persists_after_client_connection_drop() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -656,7 +561,7 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "reattach should succeed: {:?}", error); @@ -723,11 +628,12 @@ fn pane_created_after_detach_uses_configured_headless_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{error:?}"); - drain_messages(&mut stream); + support::wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) + .expect("client shell bootstrap"); let first = workspace_create(&api_socket, "attached-size"); let first_pane_id = first["result"]["root_pane"]["pane_id"] @@ -787,8 +693,8 @@ fn detached_output_preserves_last_attached_pty_size() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); - let (version, error) = - client_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40).expect("handshake should succeed"); + let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40) + .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -856,7 +762,7 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); - let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); @@ -914,44 +820,14 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); - let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) + let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. - stream_b - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - let mut received_frame = false; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - match read_server_message(&mut stream_b) { - Ok((variant, _)) => { - if variant == 1 { - received_frame = true; - break; - } - } - Err(_) => break, - } - } - assert!( - received_frame, - "reattached client should receive a Frame showing current state" - ); - - // Verify the pane content via API includes the output sent while detached. - let read_response = pane_read_recent(&api_socket, &pane_id); - - // The pane output should contain the text sent while detached. - assert!( - read_response["result"]["read"]["text"] - .as_str() - .unwrap_or_default() - .contains("DURING_DETACH"), - "pane should contain output produced while detached: {read_response}" - ); + support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) + .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 832079e8..d58fb301 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1,4 +1,4 @@ -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -12,8 +12,10 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, - send_input, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, + cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid, + send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, + wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + SERVER_MESSAGE_SERVER_SHUTDOWN, }; struct SpawnedHerdr { @@ -868,9 +870,19 @@ fn live_handoff_preserves_pane_process_io() { .as_u64() .unwrap() as u32; let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + assert!( + wait_for_message_variant( + &mut client_stream, + Duration::from_secs(5), + SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, + ) + .unwrap(), + "client shell should receive a complete snapshot before handoff" + ); assert_ok(request( &api_socket, @@ -888,8 +900,13 @@ fn live_handoff_preserves_pane_process_io() { )); drop(spawned); assert!( - wait_for_disconnect(&mut client_stream, Duration::from_secs(5)).unwrap(), - "connected clients should disconnect during live handoff" + wait_for_message_variant( + &mut client_stream, + Duration::from_secs(5), + SERVER_MESSAGE_SERVER_SHUTDOWN, + ) + .unwrap(), + "connected client shell should receive live-handoff shutdown" ); thread::sleep(Duration::from_millis(300)); wait_for_api(&api_socket, Duration::from_secs(10)); @@ -935,6 +952,14 @@ fn live_handoff_preserves_pane_process_io() { ); wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec"); + let mut reattached_shell = UnixStream::connect(&client_socket).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "reattached client shell failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5)) + .expect("fresh client shell should receive restored snapshot before pane content"); + let _ = request( &api_socket, serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), @@ -1020,10 +1045,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5)); @@ -1111,10 +1139,13 @@ pathlib.Path({received:?}).write_text(data.hex()) wait_for_socket(&client_socket, Duration::from_secs(5)); let mut client_stream = UnixStream::connect(&client_socket).unwrap(); - let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + let (server_protocol, error) = + client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap(); assert_eq!(server_protocol, protocol); - assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + assert!(error.is_none(), "client shell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5)) + .expect("client shell should receive restored state before sending input"); + send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap(); wait_for_file_contains( &received_marker, diff --git a/tests/multi_client.rs b/tests/multi_client.rs index 20deedaf..83fb0b00 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -1,10 +1,9 @@ -//! Integration tests for multi-client server behavior. +//! Gate B integration tests for the ClientShell protocol. -mod support; +pub mod support; -use std::collections::VecDeque; use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -12,20 +11,21 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; use serde_json::Value; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, + register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, + wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants, + CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, }; fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); + .unwrap() + .as_nanos(); PathBuf::from(format!( - "/tmp/herdr-multi-client-test-{}-{nanos}", + "/tmp/herdr-multi-client-{}-{nanos}", std::process::id() )) } @@ -39,45 +39,26 @@ impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); let _ = self.child.kill(); - if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { let mut status = 0; - let result = - unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; - if result == pid as libc::pid_t || result == -1 { + let done = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; + if done == pid as libc::pid_t || done == -1 { break; } thread::sleep(Duration::from_millis(20)); } - unregister_spawned_herdr_pid(Some(pid)); } } } -fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { - drop(spawned); - cleanup_test_base(&base); -} - -fn wait_for_child_exit(child: &mut Box) { - let _ = child.kill(); - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if child.try_wait().ok().flatten().is_some() { - return; - } - thread::sleep(Duration::from_millis(25)); - } -} - fn test_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + .unwrap_or_else(|p| p.into_inner()) } fn wait_for_socket(path: &Path, timeout: Duration) { @@ -94,24 +75,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) { fn wait_for_file(path: &Path, timeout: Duration) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if path.exists() && UnixStream::connect(path).is_ok() { + if path.exists() { return; } thread::sleep(Duration::from_millis(25)); } - panic!("socket did not accept connections at {}", path.display()); + panic!("socket did not appear at {}", path.display()); } -fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) -> SpawnedHerdr { - fs::create_dir_all(config_home.join("herdr")).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join("herdr/config.toml"), - "onboarding = false\n", - ) - .unwrap(); - +fn spawn_server(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + fs::create_dir_all(config.join("herdr")).unwrap(); + fs::create_dir_all(runtime).unwrap(); + register_runtime_dir(runtime); + fs::write(config.join("herdr/config.toml"), "onboarding = false\n").unwrap(); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -120,32 +96,25 @@ fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) pixel_height: 0, }) .unwrap(); - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("server"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", api_socket_path); + cmd.env("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); - let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); - SpawnedHerdr { _master: pair.master, child, } } -fn spawn_client_process( - config_home: &Path, - runtime_dir: &Path, - api_socket_path: &Path, -) -> SpawnedHerdr { - register_runtime_dir(runtime_dir); +fn spawn_client(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { + register_runtime_dir(runtime); let pair = native_pty_system() .openpty(PtySize { rows: 24, @@ -154,177 +123,86 @@ fn spawn_client_process( pixel_height: 0, }) .unwrap(); - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", api_socket_path); + cmd.env("XDG_CONFIG_HOME", config); + cmd.env("XDG_RUNTIME_DIR", runtime); + cmd.env("HERDR_SOCKET_PATH", api); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); - let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); - SpawnedHerdr { _master: pair.master, child, } } -fn server_log_path(config_home: &Path) -> PathBuf { - let app_dir = if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - }; - config_home.join(app_dir).join("herdr-server.log") +fn cleanup(server: SpawnedHerdr, base: PathBuf) { + drop(server); + cleanup_test_base(&base); } -fn count_log_occurrences(path: &Path, needle: &str) -> usize { - fs::read_to_string(path) - .ok() - .map(|text| text.lines().filter(|line| line.contains(needle)).count()) - .unwrap_or(0) -} - -fn log_tail(path: &Path, lines: usize) -> String { - let Ok(text) = fs::read_to_string(path) else { - return format!("could not read {}", path.display()); - }; - let mut tail = VecDeque::with_capacity(lines); - for line in text.lines() { - if tail.len() == lines { - tail.pop_front(); - } - tail.push_back(line.to_string()); - } - tail.into_iter().collect::>().join("\n") -} - -fn wait_for_log_occurrence_count( - path: &Path, - needle: &str, - min_count: usize, - timeout: Duration, -) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if count_log_occurrences(path, needle) >= min_count { - return true; - } - thread::sleep(Duration::from_millis(40)); - } - false -} - -fn ping_socket(socket_path: &Path) -> String { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); - writeln!( - stream, - "{{\"id\":\"ping\",\"method\":\"ping\",\"params\":{{}}}}" - ) - .unwrap(); - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response).unwrap(); - response.trim().to_string() -} - -fn send_json_request(socket_path: &Path, request: &str) -> Value { - let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); +fn api_request(socket: &Path, request: &str) -> Value { + let mut stream = UnixStream::connect(socket).unwrap(); writeln!(stream, "{request}").unwrap(); - - let mut reader = BufReader::new(stream); let mut response = String::new(); - reader.read_line(&mut response).unwrap(); - - serde_json::from_str(&response).expect("response should be valid JSON") + BufReader::new(stream).read_line(&mut response).unwrap(); + serde_json::from_str(&response).unwrap() } -fn create_workspace_and_root_pane(socket_path: &Path, label: &str) -> (String, String) { - let response = send_json_request( - socket_path, - &format!( - "{{\"id\":\"ws_create\",\"method\":\"workspace.create\",\"params\":{{\"label\":\"{label}\"}}}}" - ), +fn create_pane(socket: &Path, label: &str) -> String { + let result = api_request( + socket, + &format!(r#"{{"id":"create","method":"workspace.create","params":{{"label":"{label}"}}}}"#), ); - - if response.get("error").is_some() { - panic!("workspace.create failed: {response}"); - } - - let workspace_id = response - .pointer("/result/workspace/workspace_id") - .and_then(Value::as_str) - .expect("workspace.create should return workspace id") - .to_string(); - - let pane_id = response + assert!( + result.get("error").is_none(), + "workspace.create failed: {result}" + ); + result .pointer("/result/root_pane/pane_id") - .and_then(Value::as_str) - .expect("workspace.create should return root pane id") - .to_string(); - - (workspace_id, pane_id) + .unwrap() + .as_str() + .unwrap() + .into() } -fn report_idle_agent(socket_path: &Path, pane_id: &str) { - let response = send_json_request( - socket_path, +fn pane_input(socket: &Path, pane: &str, text: &str) { + let escaped = text.replace('"', "\\\""); + let result = api_request( + socket, &format!( - r#"{{"id":"report_agent","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"multi-client-test"}}}}"# + r#"{{"id":"input","method":"pane.send_input","params":{{"pane_id":"{pane}","text":"{escaped}","keys":["Enter"]}}}}"# ), ); assert!( - response.get("error").is_none(), - "pane.report_agent should succeed: {response}" + result.get("error").is_none(), + "pane.send_input failed: {result}" ); } -fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) { - let request = format!( - "{{\"id\":\"send_input\",\"method\":\"pane.send_input\",\"params\":{{\"pane_id\":\"{pane_id}\",\"text\":\"{}\",\"keys\":[\"Enter\"]}}}}", - text.replace('"', "\\\"") - ); - let response = send_json_request(socket_path, &request); - if response.get("error").is_some() { - panic!("pane.send_input failed: {response}"); - } -} - -fn pane_read_recent(socket_path: &Path, pane_id: &str, lines: usize) -> String { - let response = send_json_request( - socket_path, +fn pane_text(socket: &Path, pane: &str) -> String { + let result = api_request( + socket, &format!( - "{{\"id\":\"pane_read\",\"method\":\"pane.read\",\"params\":{{\"pane_id\":\"{pane_id}\",\"source\":\"recent\",\"lines\":{lines}}}}}" + r#"{{"id":"read","method":"pane.read","params":{{"pane_id":"{pane}","source":"recent","lines":200}}}}"# ), ); - - if response.get("error").is_some() { - panic!("pane.read failed: {response}"); - } - - response + result .pointer("/result/read/text") .and_then(Value::as_str) .unwrap_or_default() - .to_string() + .into() } -fn pane_read_recent_contains( - socket_path: &Path, - pane_id: &str, - needle: &str, - timeout: Duration, -) -> bool { +fn pane_contains(socket: &Path, pane: &str, needle: &str, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { - if pane_read_recent(socket_path, pane_id, 200).contains(needle) { + if pane_text(socket, pane).contains(needle) { return true; } thread::sleep(Duration::from_millis(50)); @@ -332,844 +210,168 @@ fn pane_read_recent_contains( false } -fn parse_size_after_marker(text: &str, marker: &str) -> Option<(u16, u16)> { - let mut seen_marker = false; - for line in text.lines() { - if !seen_marker { - if line.contains(marker) { - seen_marker = true; - } - continue; - } - - let mut parts = line.split_whitespace(); - let Some(rows_raw) = parts.next() else { - continue; - }; - let Some(cols_raw) = parts.next() else { - continue; - }; - - let Ok(rows) = rows_raw.parse::() else { - continue; - }; - let Ok(cols) = cols_raw.parse::() else { - continue; - }; - - return Some((rows, cols)); - } - - None +fn shell(socket: &Path, cols: u16, rows: u16) -> UnixStream { + let mut stream = UnixStream::connect(socket).unwrap(); + let (version, error) = + client_shell_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).unwrap(); + assert_eq!(version, CURRENT_PROTOCOL); + assert!(error.is_none(), "ClientShell handshake failed: {error:?}"); + wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)).unwrap(); + stream } -fn try_read_pane_tty_size( - socket_path: &Path, - pane_id: &str, - timeout: Duration, -) -> Option<(u16, u16)> { +fn tty_size(socket: &Path, pane: &str, timeout: Duration) -> (u16, u16) { let marker = format!( - "SIZE_MARKER_{}_{}", - std::process::id(), + "SIZE_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) + .unwrap() + .as_nanos() ); - - pane_send_input(socket_path, pane_id, &format!("echo {marker}; stty size")); - + pane_input(socket, pane, &format!("echo {marker}; stty size")); let deadline = Instant::now() + timeout; while Instant::now() < deadline { - let text = pane_read_recent(socket_path, pane_id, 200); - if let Some(size) = parse_size_after_marker(&text, &marker) { - return Some(size); + let mut found = false; + for line in pane_text(socket, pane).lines() { + if line.contains(&marker) { + found = true; + continue; + } + if found { + let mut words = line.split_whitespace(); + if let (Some(r), Some(c)) = (words.next(), words.next()) { + if let (Ok(r), Ok(c)) = (r.parse(), c.parse()) { + return (r, c); + } + } + } } thread::sleep(Duration::from_millis(50)); } - - None -} - -fn read_pane_tty_size(socket_path: &Path, pane_id: &str, timeout: Duration) -> (u16, u16) { - if let Some(size) = try_read_pane_tty_size(socket_path, pane_id, timeout) { - return size; - } - - let snapshot = pane_read_recent(socket_path, pane_id, 200); - panic!( - "did not observe tty size after marker. pane output:\n{}", - snapshot - ); -} - -// --------------------------------------------------------------------------- -// Minimal bincode v2 varint helpers for protocol tests -// --------------------------------------------------------------------------- - -fn encode_varint_u32(v: u32) -> Vec { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -fn frame_message(payload: &[u8]) -> Vec { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -fn is_timeout(err: &io::Error) -> bool { - matches!( - err.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) -} - -fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, _consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - Ok(variant) -} - -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // ClientMessage::Hello = variant 0 - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - stream - .write_all(&frame_message(&hello_payload)) - .map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read ServerMessage::Welcome = variant 0 - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - let mut offset = 0; - let (variant, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!("expected Welcome variant 0, got {variant}")); - } - - let (_server_version, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - let (_encoding, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - - if offset >= payload.len() { - return Err("payload too short for Welcome.error option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - if option_tag == 1 { - let (str_len, consumed) = decode_varint_u32(&payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - if offset + str_len > payload.len() { - return Err("payload too short for welcome error string".into()); - } - let err = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - return Err(format!("handshake rejected: {err}")); - } - - Ok(()) -} - -fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { - let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).expect("handshake should succeed"); - stream -} - -fn send_client_input(stream: &mut UnixStream, data: &[u8]) { - // ClientMessage::Input = variant 1 - let payload = { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - buf - }; - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -fn send_client_detach(stream: &mut UnixStream) { - // ClientMessage::Detach = variant 4 - let payload = encode_varint_u32(4); - stream.write_all(&frame_message(&payload)).unwrap(); - stream.flush().unwrap(); -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct FrameWire { - cells: Vec, - width: u16, - height: u16, - cursor: Option, - hyperlinks: Vec, - graphics: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -struct CellWire { - symbol: String, - fg: u32, - bg: u32, - modifier: u16, - skip: bool, - hyperlink: Option, -} - -#[derive(Debug, Deserialize)] -struct CursorWire { - x: u16, - y: u16, - visible: bool, - shape: u8, -} - -fn decode_frame_payload(payload: &[u8]) -> io::Result { - bincode::serde::decode_from_slice(payload, bincode::config::standard()) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string())) - .and_then(|(frame, consumed): (FrameWire, usize)| { - if consumed != payload.len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "frame payload had trailing bytes: consumed={}, len={}", - consumed, - payload.len() - ), - )); - } - Ok(frame) - }) -} - -fn read_server_message_payload( - stream: &mut UnixStream, - timeout: Duration, -) -> io::Result<(u32, Vec)> { - stream.set_read_timeout(Some(timeout))?; - - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - if len == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "zero-length payload", - )); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload)?; - - let (variant, consumed) = decode_varint_u32(&payload, 0) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - Ok((variant, payload[consumed..].to_vec())) -} - -fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) { - let deadline = Instant::now() + max_drain; - while Instant::now() < deadline { - match read_server_variant(stream, Duration::from_millis(50)) { - Ok(_) => {} - Err(err) if is_timeout(&err) => break, - Err(_) => break, - } - } -} - -fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let slice = remaining.min(Duration::from_millis(75)); - match read_server_variant(stream, slice) { - Ok(1) => return true, // ServerMessage::Frame - Ok(_) => {} - Err(err) if is_timeout(&err) => {} - Err(_) => return false, - } - } - false -} - -fn wait_for_frame_matching_with_snapshots( - stream: &mut UnixStream, - timeout: Duration, - predicate: impl Fn(&FrameWire) -> bool, -) -> io::Result<(bool, Vec)> { - let deadline = Instant::now() + timeout; - let mut snapshots = VecDeque::with_capacity(5); - while Instant::now() < deadline { - let slice = deadline - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(80)); - match read_server_message_payload(stream, slice) { - Ok((1, frame_payload)) => { - let frame = decode_frame_payload(&frame_payload)?; - if snapshots.len() == 5 { - snapshots.pop_front(); - } - snapshots.push_back(frame_text(&frame)); - if predicate(&frame) { - return Ok((true, snapshots.into_iter().collect())); - } - } - Ok((_variant, _payload)) => {} - Err(err) if is_timeout(&err) => {} - Err(err) => return Err(err), - } - } - - Ok((false, snapshots.into_iter().collect())) -} - -fn frame_text(frame: &FrameWire) -> String { - if frame.cells.is_empty() { - return String::new(); - } - - let row_width = frame.width.max(1) as usize; - let mut full_text = String::new(); - - for row in frame.cells.chunks(row_width) { - for cell in row { - let _ = (cell.fg, cell.bg, cell.modifier, cell.skip); - full_text.push_str(&cell.symbol); - } - full_text.push('\n'); - } - - let _ = (frame.height, frame.graphics.len()); - if let Some(cursor) = frame.cursor.as_ref() { - let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape); - } - - full_text -} - -fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool { - frame_text(frame).contains(needle) -} - -fn agent_panel_starts_with(frame: &FrameWire, agent_label: &str) -> bool { - frame_text(frame) - .lines() - .skip_while(|line| !line.contains("agents")) - .skip(1) - .find(|line| line.contains("agent-")) - .is_some_and(|line| line.contains(agent_label)) + panic!("pane did not report tty size: {}", pane_text(socket, pane)); } #[test] -fn multi_client_allows_multiple_simultaneous_connections() { +fn effective_size_uses_smallest_foreground_client_and_recovers_on_disconnect() { 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 120, 40); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "effective-size"); + let mut large = shell(&clients, 120, 40); + let mut small = shell(&clients, 80, 24); + let reduced = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - wait_for_frame(&mut client_a, Duration::from_secs(2)), - "client A should receive frames" + reduced.0 <= 24 && reduced.1 <= 80, + "small ClientShell should determine effective size: {reduced:?}" ); - assert!( - wait_for_frame(&mut client_b, Duration::from_secs(2)), - "client B should receive frames" - ); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain responsive: {ping}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_effective_size_shrinks_when_smaller_client_joins() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-shrink"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - let large_only_size = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - let mut small = connect_raw_client(&client_socket, 80, 24); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut last_seen_size = None; - let mut size_with_small_client = None; - while Instant::now() < deadline { - if let Some(size) = - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)) - { - last_seen_size = Some(size); - if size.0 < large_only_size.0 && size.1 < large_only_size.1 { - size_with_small_client = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_with_small_client.is_some(), - "effective pane size should shrink when smaller client joins: before={large_only_size:?}, last_seen={last_seen_size:?}" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn non_foreground_client_render_preserves_agent_panel_scroll() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for index in 1..=23 { - let (_, pane_id) = - create_workspace_and_root_pane(&api_socket, &format!("agent-{index:02}")); - report_idle_agent(&api_socket, &pane_id); - } - - let mut setup_client = connect_raw_client(&client_socket, 106, 40); - assert!(wait_for_frame(&mut setup_client, Duration::from_secs(2))); - drain_server_messages(&mut setup_client, Duration::from_millis(250)); - - let wheel_down = b"\x1b[<65;10;30M"; - send_client_input(&mut setup_client, &wheel_down.repeat(20)); - let (reached_bottom, setup_frames) = wait_for_frame_matching_with_snapshots( - &mut setup_client, + send_detach(&mut small).unwrap(); + drop(small); + assert!(wait_for_message_variant( + &mut large, Duration::from_secs(3), - |frame| agent_panel_starts_with(frame, "agent-16"), + SERVER_MESSAGE_PANE_SURFACE ) - .expect("setup frame decoding should succeed"); + .unwrap()); + let restored = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - reached_bottom, - "40-row client should scroll the agent panel to its final page; frames:\n{}", - setup_frames.join("\n--- frame ---\n") + restored.0 > reduced.0 && restored.1 > reduced.1, + "size should recover: {reduced:?} -> {restored:?}" ); - send_client_detach(&mut setup_client); - drop(setup_client); - - let mut tall_background = connect_raw_client(&client_socket, 106, 64); - assert!(wait_for_frame(&mut tall_background, Duration::from_secs(2))); - let mut probe = connect_raw_client(&client_socket, 106, 40); - let (started_at_tall_limit, initial_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-10") - }) - .expect("initial probe frame decoding should succeed"); - assert!( - started_at_tall_limit, - "tall client should normalize the shared scroll before the probe attaches; frames:\n{}", - initial_frames.join("\n--- frame ---\n") - ); - drain_server_messages(&mut probe, Duration::from_millis(250)); - - send_client_input(&mut probe, wheel_down); - let (scrolled, probe_frames) = - wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| { - agent_panel_starts_with(frame, "agent-11") - }) - .expect("probe frame decoding should succeed"); - assert!( - scrolled, - "background client projection must not undo the foreground wheel event; frames:\n{}", - probe_frames.join("\n--- frame ---\n") - ); - - cleanup_spawned_herdr(server, base); + cleanup(server, base); } #[test] -fn multi_client_broadcasts_frame_updates_to_all_clients() { +fn api_pane_output_is_fanned_out_as_pane_surface_updates() { 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut client_a = connect_raw_client(&client_socket, 100, 30); - let mut client_b = connect_raw_client(&client_socket, 100, 30); - - // Ensure we have an active pane that can reflect input changes. - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "broadcast-client-a-to-b"); - - // Drain initial frames so we measure the frame caused by new input. - drain_server_messages(&mut client_a, Duration::from_millis(300)); - drain_server_messages(&mut client_b, Duration::from_millis(300)); - + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let pane = create_pane(&api, "fanout"); + let mut a = shell(&clients, 100, 30); + let mut b = shell(&clients, 100, 30); + drain_messages(&mut a); + drain_messages(&mut b); let marker = format!( - "MB{}", + "FANOUT_{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) + .unwrap() + .as_millis() ); + pane_input(&api, &pane, &format!("printf '{marker}\\n'")); + assert!(pane_contains(&api, &pane, &marker, Duration::from_secs(5))); + let surface_updates = [ + SERVER_MESSAGE_PANE_SURFACE, + SERVER_MESSAGE_PANE_SURFACE_PATCH, + ]; + assert!(wait_for_message_variants(&mut a, Duration::from_secs(5), &surface_updates).unwrap()); + assert!(wait_for_message_variants(&mut b, Duration::from_secs(5), &surface_updates).unwrap()); + cleanup(server, base); +} - send_client_input(&mut client_a, format!("echo {marker}\n").as_bytes()); - if !pane_read_recent_contains(&api_socket, &pane_id, &marker, Duration::from_secs(5)) { - panic!( - "pane output should include client A marker so broadcast reflects a real state change. pane output:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - log_tail(&server_log_path(&config_home), 80) - ); +#[test] +fn crashed_client_shell_does_not_affect_survivor() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + let mut survivor = shell(&clients, 100, 30); + let crashed = spawn_client(&config, &runtime, &api); + // Give the supported client process time to complete its ClientShell hello; + // the point of this test is a connected peer dying, not a failed launch. + thread::sleep(Duration::from_secs(1)); + let pid = crashed.child.process_id().unwrap(); + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); } - let (received, client_b_frames) = - wait_for_frame_matching_with_snapshots(&mut client_b, Duration::from_secs(10), |frame| { - frame_contains_text(frame, &marker) - }) - .expect("frame decoding should succeed"); - - assert!( - received, - "client B should receive a broadcast frame containing client A marker. pane output:\n{}\nclient B frame snapshots:\n{}\nserver log tail:\n{}", - pane_read_recent(&api_socket, &pane_id, 200), - client_b_frames.join("\n--- frame ---\n"), - log_tail(&server_log_path(&config_home), 80) - ); - - cleanup_spawned_herdr(server, base); + drop(crashed); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + pane_input(&api, &create_pane(&api, "survivor"), "printf 'survivor\\n'"); + assert!(wait_for_message_variant( + &mut survivor, + Duration::from_secs(5), + SERVER_MESSAGE_PANE_SURFACE + ) + .unwrap()); + cleanup(server, base); } #[test] -fn multi_client_disconnect_recalculates_to_next_smallest() { +fn rapid_client_shell_connect_disconnect_remains_healthy() { 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = - create_workspace_and_root_pane(&api_socket, "size-next-smallest"); - - let mut c120 = connect_raw_client(&client_socket, 120, 40); - let mut c100 = connect_raw_client(&client_socket, 100, 30); - let mut c80 = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut c120, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c100, Duration::from_secs(2))); - assert!(wait_for_frame(&mut c80, Duration::from_secs(2))); - - let size_with_three = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut c100, Duration::from_millis(250)); - - // Smallest client disconnects; effective size should increase to the next-smallest. - send_client_detach(&mut c80); - drop(c80); - - assert!( - wait_for_frame(&mut c100, Duration::from_secs(2)), - "next-smallest client should receive resized-up frame" - ); - - let deadline = Instant::now() + Duration::from_secs(8); - let mut size_after_smallest_disconnect = None; - while Instant::now() < deadline { - let maybe_size = try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400)); - if let Some(size) = maybe_size { - if size.0 > size_with_three.0 && size.1 > size_with_three.1 { - size_after_smallest_disconnect = Some(size); - break; - } - } - thread::sleep(Duration::from_millis(60)); - } - - assert!( - size_after_smallest_disconnect.is_some(), - "effective pane size should increase after smallest disconnects: before={:?}, last_seen={:?}", - size_with_three, - try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(300)) - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_smallest_leaving_resizes_up_for_remaining_clients() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-resize-up"); - - let mut large = connect_raw_client(&client_socket, 120, 40); - let mut small = connect_raw_client(&client_socket, 80, 24); - - assert!(wait_for_frame(&mut large, Duration::from_secs(2))); - assert!(wait_for_frame(&mut small, Duration::from_secs(2))); - - let size_with_small_client = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - drain_server_messages(&mut large, Duration::from_millis(250)); - - send_client_detach(&mut small); - drop(small); - - // Remaining client should receive a new (larger) frame. - assert!( - wait_for_frame(&mut large, Duration::from_secs(2)), - "remaining client should receive resized-up frame" - ); - - let size_after_small_leaves = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5)); - - assert!( - size_after_small_leaves.0 > size_with_small_client.0 - && size_after_small_leaves.1 > size_with_small_client.1, - "remaining clients should get larger effective pane size after smallest leaves: before={:?}, after={:?}", - size_with_small_client, - size_after_small_leaves - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_client_crash_sigkill_does_not_affect_server() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - let mut survivor = connect_raw_client(&client_socket, 100, 30); - assert!(wait_for_frame(&mut survivor, Duration::from_secs(2))); - - let log_path = server_log_path(&config_home); - let connected_before = count_log_occurrences(&log_path, "client connected"); - - let crashing_client = spawn_client_process(&config_home, &runtime_dir, &api_socket); - - let attached_before_kill = wait_for_log_occurrence_count( - &log_path, - "client connected", - connected_before + 1, - Duration::from_secs(8), - ); - assert!( - attached_before_kill, - "thin client must complete handshake/attachment before SIGKILL" - ); - - if let Some(pid) = crashing_client.child.process_id() { - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGKILL); - } - } - let mut crashing_client = crashing_client; - wait_for_child_exit(&mut crashing_client.child); - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should stay healthy after SIGKILLed client: {ping}" - ); - - drain_server_messages(&mut survivor, Duration::from_millis(250)); - send_client_input(&mut survivor, b"echo survivor-still-works\n"); - assert!( - wait_for_frame(&mut survivor, Duration::from_secs(2)), - "remaining client should continue receiving frames" - ); - - cleanup_spawned_herdr(server, base); -} - -#[test] -fn multi_client_rapid_connect_disconnect_stress_10_cycles() { - 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 client_socket = runtime_dir.join("herdr-client.sock"); - - let server = spawn_server(&config_home, &runtime_dir, &api_socket); - wait_for_socket(&api_socket, Duration::from_secs(10)); - wait_for_file(&client_socket, Duration::from_secs(10)); - - for i in 0..10u16 { - let mut client = connect_raw_client(&client_socket, 80 + i, 24 + (i % 4)); - let _ = wait_for_frame(&mut client, Duration::from_millis(500)); - send_client_detach(&mut client); + let config = base.join("config"); + let runtime = base.join("runtime"); + let api = runtime.join("herdr.sock"); + let clients = runtime.join("herdr-client.sock"); + let server = spawn_server(&config, &runtime, &api); + wait_for_socket(&api, Duration::from_secs(10)); + wait_for_file(&clients, Duration::from_secs(10)); + for i in 0..10 { + let mut client = shell(&clients, 80 + i, 24); + send_detach(&mut client).unwrap(); drop(client); - thread::sleep(Duration::from_millis(40)); } - - let ping = ping_socket(&api_socket); - assert!( - ping.contains("pong"), - "server should remain healthy after rapid connect/disconnect: {ping}" - ); - - let mut final_client = connect_raw_client(&client_socket, 100, 30); - assert!( - wait_for_frame(&mut final_client, Duration::from_secs(2)), - "new client should still connect and receive frames after stress" - ); - - cleanup_spawned_herdr(server, base); + let final_client = shell(&clients, 100, 30); + drop(final_client); + let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#); + assert!(response.to_string().contains("pong")); + cleanup(server, base); } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 5abb5ace..f6eb0771 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -1,6 +1,6 @@ //! Integration tests for headless server mode. -mod support; +pub mod support; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; @@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, + cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, CURRENT_PROTOCOL, }; @@ -151,207 +151,6 @@ fn ping_socket(socket_path: &Path) -> String { response.trim().to_string() } -/// Sends a Hello message over the client socket and reads the Welcome response. -/// Uses bincode v2 wire format: [u32LE length][bincode payload] -/// bincode v2 standard config uses VarintEncoding: -/// - Integers < 251 are encoded as a single byte -/// - Enum variant index is encoded as u32 varint -/// - Option discriminant is always a single byte (0=None, 1=Some) -/// - String: length (varint) + UTF-8 bytes -fn client_handshake( - stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, -) -> Result<(u32, Option), String> { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .map_err(|e| e.to_string())?; - - // Encode Hello message using bincode v2 varint format. - // ClientMessage::Hello is variant 0. - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - let framed = frame_message(&hello_payload); - stream.write_all(&framed).map_err(|e| e.to_string())?; - stream.flush().map_err(|e| e.to_string())?; - - // Read the framed response. - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?; - let len = u32::from_le_bytes(len_buf) as usize; - - if len > 2 * 1024 * 1024 { - return Err(format!("oversized response: {len}")); - } - - let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload).map_err(|e| e.to_string())?; - - // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option } - decode_welcome(&payload) -} - -/// Encode a varint u32 value according to bincode v2 VarintEncoding. -fn encode_varint_u32(v: u32) -> Vec { - if v < 251 { - vec![v as u8] - } else if v < 65536 { - let mut buf = vec![251u8]; - buf.extend_from_slice(&(v as u16).to_le_bytes()); - buf - } else { - let mut buf = vec![252u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode a varint u16 value. -fn encode_varint_u16(v: u16) -> Vec { - if v < 251 { - vec![v as u8] - } else { - let mut buf = vec![251u8]; - buf.extend_from_slice(&v.to_le_bytes()); - buf - } -} - -/// Encode an enum variant with its fields. -fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec { - let mut buf = encode_varint_u32(variant_idx); - for field in fields { - buf.extend_from_slice(field); - } - buf -} - -/// Frame a message with u32LE length prefix. -fn frame_message(payload: &[u8]) -> Vec { - let len = payload.len() as u32; - let mut framed = len.to_le_bytes().to_vec(); - framed.extend_from_slice(payload); - framed -} - -/// Decode a varint u32 from a byte slice at the given offset. -/// Returns (value, bytes_consumed). -fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u32, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v as u32, 3)) - } - 252 => { - if offset + 5 > payload.len() { - return Err("payload too short for u32 varint".into()); - } - let v = u32::from_le_bytes( - payload[offset + 1..offset + 5] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 5)) - } - _ => Err(format!("unsupported varint tag: {first_byte}")), - } -} - -/// Decode a varint u16 from a byte slice at the given offset. -#[allow(dead_code)] -fn decode_varint_u16(payload: &[u8], offset: usize) -> Result<(u16, usize), String> { - if offset >= payload.len() { - return Err("payload too short for varint".into()); - } - let first_byte = payload[offset]; - match first_byte { - 0..=250 => Ok((first_byte as u16, 1)), - 251 => { - if offset + 3 > payload.len() { - return Err("payload too short for u16 varint".into()); - } - let v = u16::from_le_bytes( - payload[offset + 1..offset + 3] - .try_into() - .map_err(|e: std::array::TryFromSliceError| e.to_string())?, - ); - Ok((v, 3)) - } - _ => Err(format!("unsupported varint tag for u16: {first_byte}")), - } -} - -/// Decode a ServerMessage::Welcome from bincode v2 payload. -fn decode_welcome(payload: &[u8]) -> Result<(u32, Option), String> { - let mut offset = 0; - - // Variant index (should be 0 for Welcome) - let (variant, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - if variant != 0 { - return Err(format!( - "expected Welcome (variant 0), got variant {variant}" - )); - } - - // version: u32 - let (version, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // encoding: RenderEncoding - let (_encoding, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - - // error: Option — discriminant is always 1 byte - if offset >= payload.len() { - return Err("payload too short for Option tag".into()); - } - let option_tag = payload[offset]; - offset += 1; - - let error = if option_tag == 1 { - // Some(String) — length as varint + UTF-8 bytes - let (str_len, consumed) = decode_varint_u32(payload, offset)?; - offset += consumed; - let str_len = str_len as usize; - - if offset + str_len > payload.len() { - return Err("payload too short for string content".into()); - } - let s = String::from_utf8(payload[offset..offset + str_len].to_vec()) - .map_err(|e| e.to_string())?; - Some(s) - } else { - None - }; - - Ok((version, error)) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/support/mod.rs b/tests/support/mod.rs index f295dbbe..1da9985b 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::collections::HashSet; use std::fs; use std::io::{Read, Write}; @@ -16,6 +14,13 @@ static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; pub const CURRENT_PROTOCOL: u32 = 21; +pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3; +pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12; +pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13; +pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14; +pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19; +const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11; +const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { @@ -108,7 +113,7 @@ pub fn wait_for_file(path: &Path, timeout: Duration) { panic!("file did not appear at {}", path.display()); } -pub fn encode_varint_u32(v: u32) -> Vec { +fn encode_varint_u32(v: u32) -> Vec { if v < 251 { vec![v as u8] } else if v < 65536 { @@ -122,7 +127,7 @@ pub fn encode_varint_u32(v: u32) -> Vec { } } -pub fn encode_varint_u16(v: u16) -> Vec { +fn encode_varint_u16(v: u16) -> Vec { if v < 251 { vec![v as u8] } else { @@ -132,14 +137,14 @@ pub fn encode_varint_u16(v: u16) -> Vec { } } -pub fn frame_message(payload: &[u8]) -> Vec { +fn frame_message(payload: &[u8]) -> Vec { let len = payload.len() as u32; let mut framed = len.to_le_bytes().to_vec(); framed.extend_from_slice(payload); framed } -pub fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { +fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> { if offset >= payload.len() { return Err("payload too short for varint".into()); } @@ -220,31 +225,16 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option), String> { Ok((version, error)) } -pub fn client_handshake( +fn finish_handshake( stream: &mut UnixStream, - version: u32, - cols: u16, - rows: u16, + hello_payload: &[u8], ) -> Result<(u32, Option), String> { stream .set_read_timeout(Some(Duration::from_secs(5))) .map_err(|e| e.to_string())?; - - let hello_payload = encode_varint_enum( - 0, - &[ - &encode_varint_u32(version), - &encode_varint_u16(cols), - &encode_varint_u16(rows), - &encode_varint_u32(8), // cell_width_px - &encode_varint_u32(16), // cell_height_px - &encode_varint_u32(0), // RenderEncoding::SemanticFrame - &encode_varint_u32(0), // ClientKeybindings::Server - &encode_varint_u32(0), // ClientLaunchMode::App - ], - ); - let framed = frame_message(&hello_payload); - stream.write_all(&framed).map_err(|e| e.to_string())?; + stream + .write_all(&frame_message(hello_payload)) + .map_err(|e| e.to_string())?; stream.flush().map_err(|e| e.to_string())?; let mut len_buf = [0u8; 4]; @@ -253,12 +243,54 @@ pub fn client_handshake( if len > 2 * 1024 * 1024 { return Err(format!("oversized response: {len}")); } - let mut payload = vec![0u8; len]; stream.read_exact(&mut payload).map_err(|e| e.to_string())?; decode_welcome(&payload) } +pub fn client_handshake( + stream: &mut UnixStream, + version: u32, + cols: u16, + rows: u16, +) -> Result<(u32, Option), String> { + let hello_payload = encode_varint_enum( + 0, + &[ + &encode_varint_u32(version), + &encode_varint_u16(cols), + &encode_varint_u16(rows), + &encode_varint_u32(8), // cell_width_px + &encode_varint_u32(16), // cell_height_px + &[0], // pixel_mouse = false + ], + ); + finish_handshake(stream, &hello_payload) +} + +pub fn client_shell_handshake( + stream: &mut UnixStream, + version: u32, + surface_cols: u16, + surface_rows: u16, +) -> Result<(u32, Option), String> { + let hello_payload = encode_varint_enum( + CLIENT_MESSAGE_CLIENT_SHELL_HELLO, + &[ + &encode_varint_u32(version), + &encode_varint_u32(8), + &encode_varint_u32(16), + &encode_varint_u16(surface_cols), + &encode_varint_u16(surface_rows), + &[0], // pixel mouse disabled + &[0], // direct graphics disabled + &[0], // client-owned keybindings + &[0], // mouse capture disabled + ], + ); + finish_handshake(stream, &hello_payload) +} + pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec), String> { let mut len_buf = [0u8; 4]; stream @@ -281,16 +313,27 @@ pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec), St Ok((variant, payload[consumed..].to_vec())) } -pub fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> { - let mut buf = encode_varint_u32(1); - buf.extend_from_slice(&encode_varint_u32(data.len() as u32)); - buf.extend_from_slice(data); - let framed = frame_message(&buf); +pub fn send_client_shell_shift_enter(stream: &mut UnixStream, pane_id: &str) -> Result<(), String> { + let mut payload = encode_varint_u32(CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT); + payload.extend_from_slice(&encode_varint_u32(pane_id.len() as u32)); + payload.extend_from_slice(pane_id.as_bytes()); + payload.extend_from_slice(&encode_varint_u32(1)); // one pane input event + payload.extend_from_slice(&encode_varint_u32(0)); // Key + payload.extend_from_slice(&encode_varint_u32(1)); // Enter + payload.push(1); // Shift + payload.extend_from_slice(&encode_varint_u32(0)); // Press + payload.extend_from_slice(&encode_varint_u16(1)); + payload.push(0); // no shifted codepoint + payload.push(0); // no generated text + payload.push(0); // does not track release + payload.push(0); // no physical key id + stream - .write_all(&framed) - .map_err(|e| format!("write input: {e}"))?; - stream.flush().map_err(|e| format!("flush input: {e}"))?; - Ok(()) + .write_all(&frame_message(&payload)) + .map_err(|e| format!("write client shell key: {e}"))?; + stream + .flush() + .map_err(|e| format!("flush client shell key: {e}")) } pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> { @@ -329,6 +372,14 @@ pub fn wait_for_message_variant( stream: &mut UnixStream, timeout: Duration, variant: u32, +) -> Result { + wait_for_message_variants(stream, timeout, &[variant]) +} + +pub fn wait_for_message_variants( + stream: &mut UnixStream, + timeout: Duration, + variants: &[u32], ) -> Result { stream .set_read_timeout(Some(Duration::from_millis(200))) @@ -336,7 +387,7 @@ pub fn wait_for_message_variant( let deadline = Instant::now() + timeout; while Instant::now() < deadline { match read_server_message(stream) { - Ok((got, _)) if got == variant => return Ok(true), + Ok((got, _)) if variants.contains(&got) => return Ok(true), Ok(_) => continue, Err(_) => continue, } @@ -344,6 +395,35 @@ pub fn wait_for_message_variant( Ok(false) } +pub fn wait_for_client_shell_bootstrap( + stream: &mut UnixStream, + timeout: Duration, +) -> Result<(), String> { + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|e| e.to_string())?; + let deadline = Instant::now() + timeout; + let mut saw_snapshot = false; + while Instant::now() < deadline { + match read_server_message(stream) { + Ok((SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, _)) => saw_snapshot = true, + Ok((SERVER_MESSAGE_PANE_SURFACE, _)) if saw_snapshot => return Ok(()), + Ok((SERVER_MESSAGE_PANE_SURFACE, _)) => { + return Err("client shell pane surface arrived before its snapshot".into()); + } + Ok(_) | Err(_) => {} + } + } + Err(format!( + "timed out waiting for client shell {}", + if saw_snapshot { + "pane surface" + } else { + "snapshot" + } + )) +} + pub fn wait_for_disconnect(stream: &mut UnixStream, timeout: Duration) -> Result { stream.set_nonblocking(true).map_err(|e| e.to_string())?; let deadline = Instant::now() + timeout;