refactor: render the shell in the client (#3487)

* feat: add opt-in client-rendered shell

* feat: add client-owned shell mouse controls

* feat: complete client-owned shell chrome

* feat: add client-owned shell settings and notifications

* feat: run client shell custom commands

* feat: render popup terminals in client shell

* feat: add client-owned scrollback and copy mode

* fix: preserve client worktree trust defaults

* feat: render startup diagnostics in client shell

* feat: render onboarding in client shell

* feat: render product announcements in client shell

* feat: render release notes in client shell

* feat: render mobile switcher in client shell

* feat: render kitty graphics in client shell

* fix: preserve client shell lifecycle coherence

* refactor: route client shell commands through endpoint connections

* refactor: make the client-rendered shell the default

* refactor: remove monolithic launch mode

* refactor: complete client-rendered shell cutover

* perf: retain client pane surface updates

* perf: scale retained pane surface updates

* perf: remove remote pane input latency

* test: make client shell checks platform-independent

* fix: prevent client timer starvation

* test: fix client shell platform gates

* test: accept retained surfaces in cross-area checks

* test: make client mode assertions platform-neutral

* fix: badge only outdated integrations
This commit is contained in:
Can Celik
2026-09-01 15:48:22 +03:00
committed by GitHub
parent 8a6d697308
commit 207be3c771
204 changed files with 49286 additions and 63102 deletions
+650
View File
@@ -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": {
@@ -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
@@ -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 <host>` 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/).
@@ -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 # 対応する実行中サーバーでライブハンドオフにオプトイン
@@ -100,4 +100,4 @@ herdr --remote workbox
ローカル作業には `herdr` を使います。リモートシェル上で Herdr を tmux のように使いたいときや、スマートフォンの SSH クライアントを使っているときは `ssh you@server` してから `herdr` を使います。リモートセッション用のローカルシンクライアントが欲しいとき (ローカルクリップボードの画像貼り付けブリッジを含む) は `herdr --remote <host>` を使います。
リモートブートストラップの詳細、名前付きリモートセッション、カスタムバイナリ、ダイレクトターミナルアタッチ、`--no-session` については[永続化とリモートアクセス](/ja/docs/persistence-remote/)を参照してください。
リモートブートストラップの詳細、名前付きリモートセッション、カスタムバイナリ、ダイレクトターミナルアタッチについては[永続化とリモートアクセス](/ja/docs/persistence-remote/)を参照してください。
@@ -118,13 +118,3 @@ herdr terminal attach term_abc123
```bash
herdr terminal attach term_abc123 --takeover
```
## シングルプロセスの逃げ道
バックグラウンドのサーバー/クライアント分離なしで Herdr を実行するには `--no-session` を使います:
```bash
herdr --no-session
```
これは主にデバッグや互換性のための逃げ道です。デフォルトの永続セッションモードが通常の使い方です。
@@ -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.
@@ -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.
@@ -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 # 对受支持的运行中服务器启用实时交接
@@ -100,4 +100,4 @@ herdr --remote workbox
本地工作用 `herdr`。想让 Herdr 在远程 shell 上表现得像 tmux,或者在用手机 SSH 客户端时,用 `ssh you@server` 再 `herdr`。想要一个连接远程会话的本地瘦客户端 (包括本地剪贴板图像粘贴桥接) 时,用 `herdr --remote <host>`。
关于远程引导细节、命名远程会话、自定义二进制直接终端附加和 `--no-session`,参见[持久化与远程访问](/zh-cn/docs/persistence-remote/)。
关于远程引导细节、命名远程会话、自定义二进制直接终端附加,参见[持久化与远程访问](/zh-cn/docs/persistence-remote/)。
@@ -118,13 +118,3 @@ herdr terminal attach term_abc123
```bash
herdr terminal attach term_abc123 --takeover
```
## 单进程逃生舱
用 `--no-session` 在没有后台服务器/客户端分离的情况下运行 Herdr:
```bash
herdr --no-session
```
这主要是调试或兼容性的逃生舱。默认的持久会话模式才是常规路径。
@@ -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",
+3 -1
View File
@@ -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")
+6 -1
View File
@@ -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(_)
+22
View File
@@ -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")]
+16
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
/// Client-owned selection coordinates, validated against the pane's content revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<super::PaneSelectionReadParams>,
}
+11 -8
View File
@@ -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<crate::sound::Sound> {
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)]
+17
View File
@@ -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,
+80
View File
@@ -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<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset_from_bottom: Option<u64>,
}
#[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<f32>,
}
#[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<u64>,
}
#[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<u64>,
}
#[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<PaneTextRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
+28 -1
View File
@@ -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<PaneTextRange>,
total: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
current: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_global: Option<u64>,
},
PaneGraphicsFrameAck {
sequence: u64,
revision: u64,
@@ -210,6 +229,9 @@ pub enum ResponseResult {
changed: bool,
reason: ClientWindowTitleReason,
},
IntegrationList {
integrations: Vec<super::integrations::IntegrationInfo>,
},
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<String>,
handled: bool,
},
PluginLogList {
logs: Vec<PluginCommandLogInfo>,
},
+75
View File
@@ -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::<Request>(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::<SuccessResponse>(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::<Request>(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 {
+3
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
+10 -8
View File
@@ -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<ServerCapabilities>,
) -> std::io::Result<ServerHandle> {
start_server_inner(api_tx, event_hub, capabilities, None)
}
fn default_capabilities() -> Option<ServerCapabilities> {
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",
+94 -2071
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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(),
+13 -14
View File
@@ -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<AgentPanelEntry
}
pub(crate) fn presented_workspace_idx(app: &AppState) -> Option<usize> {
if app.mode == Mode::Navigate {
app.workspaces.get(app.selected).map(|_| app.selected)
} else {
app.active
}
app.active
}
fn normalize_source(source: &str) -> Result<String, String> {
@@ -449,6 +445,10 @@ mod tests {
state
}
fn projected_entries(state: &AppState) -> Vec<crate::ui::AgentPanelEntry> {
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"));
}
+1 -1
View File
@@ -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(),
+184 -251
View File
@@ -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<String> {
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<crate::app::actions::PaneStateUpdate> {
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<crate::app::state::ToastNotification>,
@@ -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(&params.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();
+1 -3
View File
@@ -87,8 +87,6 @@ impl App {
fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) {
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(),
+1 -1
View File
@@ -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(),
+28 -1
View File
@@ -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,
+1 -1
View File
@@ -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(),
+3 -13
View File
@@ -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,
+616 -251
View File
File diff suppressed because it is too large Load Diff
+3 -21
View File
@@ -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<String> {
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
+104 -92
View File
@@ -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<T> {
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<String>,
) -> 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(&params.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(&copy_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)]
+2 -2
View File
@@ -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>) -> String {
pub(crate) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String {
encode_error_body(
id,
ErrorBody {
+2 -2
View File
@@ -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(),
+21 -45
View File
@@ -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");
+156 -15
View File
@@ -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);
+8 -9
View File
@@ -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!(
-38
View File
@@ -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 {
-128
View File
@@ -1,128 +0,0 @@
use super::App;
impl App {
pub(super) fn update_config_file<F>(&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);
}
}
}
+8 -137
View File
@@ -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<usize> {
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<usize> {
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,
+782
View File
@@ -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<EndpointCommand>,
}
#[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<crate::protocol::ClientShellCommand> {
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<crate::config::CustomCommandKeybind> {
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(&params.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(&params) {
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>,
) -> 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<String>,
) -> 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<std::path::PathBuf>) {
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::path::PathBuf>,
) -> 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<std::path::PathBuf>,
extra_env: Vec<(String, String)>,
temp_files: Vec<std::path::PathBuf>,
) -> 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<std::path::PathBuf> {
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"));
}
}
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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,
-370
View File
@@ -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<Bytes>,
) {
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<u8> {
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"
);
}
}
File diff suppressed because it is too large Load Diff
-1051
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-831
View File
@@ -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<usize> {
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<Rect> {
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<Rect> {
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<Rect> {
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<Rect> {
self.onboarding_modal_inner(56, 7)
}
fn release_notes_body_rect(&self) -> Option<Rect> {
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<crate::pane::ScrollMetrics> {
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<ScrollbarClickTarget> {
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<usize> {
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<Rect> {
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<crate::pane::ScrollMetrics> {
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<ScrollbarClickTarget> {
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<usize> {
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<Rect> {
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<Rect> {
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<crate::pane::ScrollMetrics> {
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::<usize>();
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<ScrollbarClickTarget> {
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<usize> {
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 { .. })
));
}
}
-306
View File
@@ -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());
}
}
-693
View File
@@ -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<SettingsAction> {
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<SettingsAction> {
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<SettingsSection> {
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<usize> {
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<SettingsAction> {
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::<u16>();
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,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+323 -3828
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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,
+1 -13
View File
@@ -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(),
+13 -739
View File
@@ -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<Instant> {
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<Instant> {
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<Instant> {
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());
}
}
-198
View File
@@ -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<String> {
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<String> {
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<String> {
self.dispatch_deferred_runtime_mutation(id, Method::WorktreeRemove(params))
}
}
+3 -6
View File
@@ -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;
}
+47 -1193
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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(),
+21 -3
View File
@@ -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(),
-64
View File
@@ -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<crate::terminal_theme::HostAppearance>,
+7 -1
View File
@@ -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();
+2 -2343
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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(
+1
View File
@@ -94,6 +94,7 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
}
super::runtime::workspace_create(WorkspaceCreateParams {
source_workspace_id: None,
cwd,
focus,
label,
+582
View File
@@ -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<Vec<u8>>,
}
#[derive(Debug)]
#[cfg(unix)]
pub(super) enum AttachInputAction {
Forward(Vec<u8>),
ForwardPair(Vec<u8>, Vec<u8>),
Semantic(AttachSemanticAction),
ForwardThenSemantic(Vec<u8>, AttachSemanticAction),
Detach,
None,
}
#[derive(Debug)]
#[cfg(unix)]
pub(super) enum AttachSemanticAction {
Scroll {
source: AttachScrollSource,
direction: AttachScrollDirection,
lines: u16,
column: Option<u16>,
row: Option<u16>,
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<u8>,
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<Vec<u8>> {
self.pending_prefix.take()
}
}
#[cfg(unix)]
fn single_attach_key(data: &[u8]) -> Option<crate::input::TerminalKey> {
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<AttachSemanticAction> {
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:?}"),
}
}
}
+248
View File
@@ -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<crate::platform::ClipboardImage> {
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<crate::platform::ClipboardImage> {
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<crate::platform::ClipboardImage> {
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
}
}
+224
View File
@@ -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<u8>,
}
pub(super) struct EndpointCommandResult {
pub(super) boot_id: String,
pub(super) request_id: String,
pub(super) result: Result<ResponseResult, ClientShellEndpointError>,
}
#[derive(Default)]
pub(super) struct EndpointCommands {
queued: VecDeque<(String, Box<Request>)>,
in_flight: Option<InFlightCommand>,
}
impl EndpointCommands {
pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) {
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<u8>,
) -> io::Result<Option<EndpointCommandResult>> {
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<ResponseResult, ClientShellEndpointError> {
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);
}
}
+99
View File
@@ -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<String> },
/// 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<protocol::FramingError> 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),
}
}
}
+97
View File
@@ -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<Mutex<HashSet<u32>>> = 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<u32> {
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<u32> {
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::<u32>() {
return Some(id);
}
}
None
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
+189
View File
@@ -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<Duration>,
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<Duration>,
_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<crate::protocol::ClientSurfaceSize>,
endpoint_keybindings: bool,
mouse_capture: bool,
) -> Result<RenderEncoding, ClientError> {
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"),
))),
}
}
+1028 -2803
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -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<shell::ClientShellNotificationEffect>,
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<bool>,
mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>,
) {
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<crate::sound::Sound> {
match message {
"agent done" => Some(crate::sound::Sound::Done),
"agent attention" => Some(crate::sound::Sound::Request),
_ => None,
}
}
+287
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(),
);
}
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;
File diff suppressed because it is too large Load Diff
+343
View File
@@ -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<Vec<crate::ui::ResolvedToken>>,
}
pub(super) fn ordered_agent_pane_ids(
snapshot: &ClientShellSnapshot,
sort: crate::config::AgentPanelSortConfig,
) -> Vec<String> {
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::<Vec<_>>();
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::<Vec<_>>();
let gaps = rows
.iter()
.enumerate()
.map(|(index, _)| {
if index + 1 < rows.len() {
config.agents.row_gap
} else {
0
}
})
.collect::<Vec<_>>();
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<AgentRow> {
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::<HashMap<_, _>>();
let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>();
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",
}
}
+539
View File
@@ -0,0 +1,539 @@
use super::*;
fn restore_mode_bar(
frame: &mut FrameData,
bar: Option<Rect>,
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<FrameData> {
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)
}
}
}
+503
View File
@@ -0,0 +1,503 @@
use super::*;
pub(super) fn merged_config_diagnostic(
local: Option<&str>,
endpoint: Option<&str>,
) -> Option<String> {
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<String>) {
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::<Vec<_>>();
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<String>) -> 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<String> {
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<String> {
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)
);
}
}
+469
View File
@@ -0,0 +1,469 @@
use super::*;
impl ClientContextMenuOverlay {
pub(super) fn items(&self) -> Vec<ClientContextMenuItem> {
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<String>,
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)
}
_ => {}
}
}
}
+871
View File
@@ -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<PaneHit> {
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;
}
}
+110
View File
@@ -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;
}
}
+67
View File
@@ -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<u8> {
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,
);
}
}
File diff suppressed because it is too large Load Diff
+999
View File
@@ -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<Line<'static>>,
background: Color,
target: Option<ClientMobileTarget>,
}
impl MobileItem {
fn section(label: impl Into<String>, 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::<Vec<_>>();
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::<usize>();
let pending = counts[..3].iter().map(|(_, _, count)| count).sum::<usize>();
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::<usize>();
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<MobileItem> {
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),
);
}
}
}
File diff suppressed because it is too large Load Diff
+418
View File
@@ -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 = &notification.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 = &notification.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<ClientShellNotificationEffect>, 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<ClientShellNotificationEffect>, 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, &notification, true, &palette);
assert_eq!(rect, Rect::new(0, 18, 44, 1));
let text = buffer
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
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,
&notification(),
position,
1,
&palette,
);
assert!(rect.y >= area.y);
assert!(rect.bottom() <= area.bottom());
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) sidebar_section_split: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) sidebar_collapsed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(super) collapsed_groups: Vec<String>,
}
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<ClientChromePreferences> {
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::<Vec<_>>();
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");
}
}
+313
View File
@@ -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<Rect> {
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<String>,
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<usize>,
pub(super) selected_workspace_id: Option<&'a str>,
pub(super) dragged_workspace_id: Option<&'a str>,
pub(super) workspace_drop_indicator_row: Option<u16>,
}
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
}
+98
View File
@@ -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);
}
}
+385
View File
@@ -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::<Vec<_>>(),
_ => 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<crate::api::schema::ResponseResult, ClientShellEndpointError>,
) -> (bool, Vec<ClientShellAction>) {
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
}
}
+414
View File
@@ -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<OverlayRender> {
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),
);
}
}

Some files were not shown because too many files have changed in this diff Show More