From 2c7c8beb079c5fd58a2e53efd2058df050ad9f55 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 15 Jul 2026 03:58:07 +0300 Subject: [PATCH] feat: add floating popup panes refs #1125 --- docs/next/CHANGELOG.md | 1 + docs/next/api/herdr-api.schema.json | 88 +++++ .../src/content/docs/cli-reference.mdx | 9 +- .../src/content/docs/configuration.mdx | 14 +- .../next/website/src/content/docs/plugins.mdx | 25 +- .../website/src/content/docs/socket-api.mdx | 21 +- src/api/mod.rs | 1 + src/api/schema.rs | 2 + src/api/schema/plugins.rs | 10 + src/api/schema/tests.rs | 26 +- src/api/server.rs | 1 + src/app/api.rs | 16 + src/app/api/plugins/manifest.rs | 15 + src/app/api/plugins/mod.rs | 256 ++++++++++++++- src/app/api/plugins/panes.rs | 33 ++ src/app/input/mod.rs | 74 +++++ src/app/input/navigate.rs | 20 ++ src/app/input/terminal.rs | 274 ++++++++++++++-- src/app/mod.rs | 187 ++++++++++- src/app/popup.rs | 309 ++++++++++++++++++ src/app/runtime.rs | 6 +- src/app/state.rs | 28 +- src/cli/plugin.rs | 36 +- src/config/keybinds.rs | 76 +++++ src/main.rs | 1 + src/pane.rs | 118 +++++-- src/popup_size.rs | 252 ++++++++++++++ src/server/headless.rs | 131 ++++++++ src/server/render_stream.rs | 29 +- src/terminal/runtime.rs | 15 + src/ui.rs | 12 +- src/ui/panes.rs | 64 +++- src/workspace/tab.rs | 3 + 33 files changed, 2049 insertions(+), 104 deletions(-) create mode 100644 src/app/popup.rs create mode 100644 src/popup_size.rs diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 51407fed..a3894b34 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -41,6 +41,7 @@ - Added `herdr terminal session control` for bridge processes that need live ANSI frames plus input, resize, scroll, release, and takeover authority. - Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448) - Added Japanese and Simplified Chinese website docs. +- Added session-modal popup terminal panes for custom command keybindings and plugin panes, so tools such as `lazygit` or plugin pickers can open without changing the tab layout. (#1125) ### Changed - The mobile switcher now starts from an agents-first summary and renders worktrees as a tree, making narrow terminals easier to scan. diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index d146e11b..a9712784 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -3052,6 +3052,16 @@ "default": false, "type": "boolean" }, + "height": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/PopupSize" + }, + { + "type": "null" + } + ] + }, "placement": { "anyOf": [ { @@ -3071,6 +3081,16 @@ "null" ] }, + "width": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/PopupSize" + }, + { + "type": "null" + } + ] + }, "workspace_id": { "type": [ "string", @@ -3087,6 +3107,7 @@ "PluginPanePlacement": { "enum": [ "overlay", + "popup", "split", "tab", "zoomed" @@ -3175,6 +3196,21 @@ ], "type": "object" }, + "PopupSize": { + "oneOf": [ + { + "description": "Outer popup size in terminal cells, including the border.", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + { + "description": "Outer popup size as a percentage of the terminal area, for example 80%.", + "pattern": "^(100|[1-9][0-9]?)%$", + "type": "string" + } + ] + }, "ReadFormat": { "enum": [ "text", @@ -5027,6 +5063,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "popup.close", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -7756,6 +7808,16 @@ "null" ] }, + "height": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PopupSize" + }, + { + "type": "null" + } + ] + }, "id": { "type": "string" }, @@ -7774,6 +7836,16 @@ }, "title": { "type": "string" + }, + "width": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PopupSize" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -7805,6 +7877,7 @@ "PluginPanePlacement": { "enum": [ "overlay", + "popup", "split", "tab", "zoomed" @@ -7879,6 +7952,21 @@ ], "type": "string" }, + "PopupSize": { + "oneOf": [ + { + "description": "Outer popup size in terminal cells, including the border.", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + { + "description": "Outer popup size as a percentage of the terminal area, for example 80%.", + "pattern": "^(100|[1-9][0-9]?)%$", + "type": "string" + } + ] + }, "ReadFormat": { "enum": [ "text", diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 57db7484..a7c11d92 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -404,7 +404,7 @@ herdr plugin log list [--plugin ID] [--limit N] Managed terminal panes: ```bash -herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|split|tab|zoomed] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus] +herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus] herdr plugin pane focus herdr plugin pane close ``` @@ -413,7 +413,12 @@ herdr plugin pane close with the current platform. It starts a manifest-declared `[[panes]]` command as a Herdr-managed terminal pane. The manifest default is `overlay`, which opens a temporary zoomed overlay over the active pane. It can also open as a split, a -new tab, or a zoomed pane. Native non-terminal plugin panes are a later surface. +new tab, a zoomed pane, or a session-modal `popup` that does not change the tab +layout. `--width` and `--height` set the outer popup dimensions in terminal +cells or percentages such as `80%`; omitted dimensions default to half the +terminal size, and values smaller than the popup minimum are clamped. A popup +is not a Herdr pane, does not export `HERDR_PANE_ID`, and does not participate +in pane or agent APIs. Native non-terminal plugin panes are a later surface. `--env KEY=VALUE` can be repeated on process-launching commands. It applies to the newly launched process only. Herdr-managed variables such as `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, `HERDR_PANE_ID`, `HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, `HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_ENTRYPOINT_ID`, and `HERDR_PLUGIN_CONTEXT_JSON` stay authoritative when they conflict with caller-provided env. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 01d82547..0cae754c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -163,12 +163,22 @@ Custom commands use the same keybinding syntax. ```toml [[keys.command]] key = "prefix+alt+g" -type = "pane" +type = "popup" command = "lazygit" description = "run lazygit" +width = "80%" +height = "80%" ``` -`type = "pane"` opens a temporary pane and closes it when the command exits. +`type = "popup"` opens a session-modal popup without changing the tab layout. +The popup receives all terminal input, including Escape, until its command +exits. `width` and `height` are optional; omit them for the default half-size +popup, use numbers for terminal cells, or use strings like `"80%"` for a +percentage of the terminal area. Dimensions include the popup border, and +values smaller than the popup minimum are clamped. Popup commands do not receive +`HERDR_PANE_ID`; use `HERDR_ACTIVE_PANE_ID` for the underlying tiled pane. + +`type = "pane"` opens a temporary zoomed pane and closes it when the command exits. `type = "shell"` runs detached in the background. diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx index 9f57ff47..e572ef10 100644 --- a/docs/next/website/src/content/docs/plugins.mdx +++ b/docs/next/website/src/content/docs/plugins.mdx @@ -259,12 +259,27 @@ while Windows clients connect to a named pipe. CLI calls through Manifest pane `placement` defaults to `overlay`, which opens a temporary zoomed overlay over the active pane and restores the previous focus and zoom when it closes. A `plugin.pane.open` request can override the manifest placement with -`overlay`, `split`, `tab`, or `zoomed`. +`overlay`, `popup`, `split`, `tab`, or `zoomed`. -Plugin panes are normal Herdr panes after they open. Plugins can call standard -pane APIs such as `pane.move`, `pane.swap`, `pane.resize`, and `pane.zoom` -through the socket or CLI; Herdr keeps plugin pane ownership attached to the -underlying pane when it moves across tabs or workspaces. +`placement = "popup"` opens a session-modal terminal popup without changing the +tiled layout. It accepts optional `width` and `height` fields in the manifest or +open request; omit them for the default half-size popup, use numbers for outer +terminal-cell dimensions, or use strings like `"80%"` for a percentage of the +terminal area. It receives all terminal input, including Escape, and closes +when the command exits or a `popup.close` request is sent. Dimensions smaller +than the popup minimum are clamped. + +Split, tab, zoomed, and overlay plugin panes are normal Herdr panes after they +open. Plugins can call standard pane APIs such as `pane.move`, `pane.swap`, +`pane.resize`, and `pane.zoom` through the socket or CLI; Herdr keeps plugin +pane ownership attached to the underlying pane when it moves across tabs or +workspaces. A popup is a singleton session resource rather than a Herdr pane: +it has no pane ID, does not change plugin focus context, emits no pane lifecycle +events, and does not participate in pane, layout, persistence, or agent APIs. +Its process does not receive `HERDR_PANE_ID`; the underlying tiled pane remains +available through `HERDR_PLUGIN_CONTEXT_JSON`. +Opening a popup returns `ui_busy` while Settings, Copy mode, or another Herdr +modal is active, and `plugin.pane.open` returns an `ok` result after launch. On Windows, build commands, action commands, and event commands resolve common `PATHEXT` shims such as `npm.cmd`, `bun.cmd`, and `pnpm.cmd` when the bare diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index e914eabc..cc8d5675 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -104,6 +104,7 @@ Raw socket method names use dot notation: | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` | | Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Popup | `popup.close` | | Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` | | Events | `events.subscribe`, `events.wait` | @@ -504,12 +505,20 @@ Open a managed terminal UI: `plugin.pane.open` requires an installed, enabled, platform-compatible plugin, then launches the requested manifest `[[panes]]` entrypoint as an argv-backed terminal pane. Manifest pane `placement` defaults to `overlay`; request -`placement` overrides the manifest with `overlay`, `split`, `tab`, or `zoomed`. -Overlay panes target the active pane. Split and zoomed panes target an existing -pane; tab panes can target a workspace. The pane -behaves like a normal Herdr pane, but `plugin.pane.focus` and -`plugin.pane.close` only operate on panes opened through the plugin API. Focus -returns `plugin_pane_focused`; close returns `plugin_pane_closed`. +`placement` overrides the manifest with `overlay`, `popup`, `split`, `tab`, or +`zoomed`. Overlay and popup placements use the active tiled pane as launch +context. Popup terminals are session-modal and do not change the tab layout; +optional `width` and `height` fields set their outer size as terminal cells or +percentages such as `"80%"`. Omitted dimensions default to half the terminal +size, with too-small values clamped to the popup minimum. A popup has no pane +ID, remains outside all `pane.*` and agent APIs, emits no pane lifecycle events, +leaves plugin focus context on the underlying tiled pane, and does not export +`HERDR_PANE_ID` to its process. Popup launch returns `ok`; `popup.close` closes +the active popup and returns +`popup_not_open` when none exists. Split and zoomed panes target an existing +pane; tab panes can target a workspace. Split, tab, zoomed, and overlay panes +behave like normal Herdr panes, and `plugin.pane.focus` and `plugin.pane.close` +continue to operate on those panes. ## Socket transport diff --git a/src/api/mod.rs b/src/api/mod.rs index b0d7caed..364d5fc8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -64,6 +64,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneClearAgentAuthority(_) | Method::PaneReleaseAgent(_) | Method::PaneClose(_) + | Method::PopupClose(_) | Method::PluginActionInvoke(_) | Method::PluginPaneOpen(_) | Method::PluginPaneFocus(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 4449b2d9..4f5df34f 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -191,6 +191,8 @@ pub enum Method { PaneReleaseAgent(PaneReleaseAgentParams), #[serde(rename = "pane.close")] PaneClose(PaneTarget), + #[serde(rename = "popup.close")] + PopupClose(EmptyParams), #[serde(rename = "events.subscribe")] EventsSubscribe(EventsSubscribeParams), #[serde(rename = "events.wait")] diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 10e7d670..75b40f97 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -6,6 +6,7 @@ use super::common::AgentStatus; use super::common::SplitDirection; use super::panes::PaneInfo; use super::workspaces::WorkspaceWorktreeInfo; +use crate::popup_size::PopupSize; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PluginLinkParams { @@ -261,6 +262,10 @@ pub struct PluginManifestPane { pub platforms: Option>, #[serde(default)] pub placement: PluginPanePlacement, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, pub command: Vec, } @@ -407,6 +412,10 @@ pub struct PluginPaneOpenParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub placement: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_pane_id: Option, @@ -427,6 +436,7 @@ pub struct PluginPaneOpenParams { pub enum PluginPanePlacement { #[default] Overlay, + Popup, Split, Tab, Zoomed, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index adc7e61c..bc044ee9 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -814,6 +814,8 @@ fn plugin_link_list_unlink_round_trip() { description: None, platforms: None, placement: PluginPanePlacement::Overlay, + width: None, + height: None, command: vec!["bun".into(), "run".into(), "board.ts".into()], }], link_handlers: vec![PluginManifestLinkHandler { @@ -1152,10 +1154,12 @@ fn plugin_pane_open_request_round_trips() { method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.board".into(), entrypoint: "board".into(), - placement: Some(PluginPanePlacement::Zoomed), + placement: Some(PluginPanePlacement::Popup), + width: Some(crate::popup_size::PopupSize::Cells(90)), + height: Some(crate::popup_size::PopupSize::Percent(80)), workspace_id: None, - target_pane_id: Some("1-1".into()), - direction: Some(SplitDirection::Right), + target_pane_id: None, + direction: None, cwd: Some("/tmp".into()), focus: true, env: [("HERDR_ROLE".to_string(), "board".to_string())].into(), @@ -1164,7 +1168,23 @@ fn plugin_pane_open_request_round_trips() { let json = serde_json::to_value(&request).unwrap(); assert_eq!(json["method"], "plugin.pane.open"); + assert_eq!(json["params"]["placement"], "popup"); + assert_eq!(json["params"]["width"], 90); + assert_eq!(json["params"]["height"], "80%"); assert_eq!(json["params"]["env"]["HERDR_ROLE"], "board"); let restored: Request = serde_json::from_value(json).unwrap(); assert_eq!(restored, request); } + +#[test] +fn popup_close_request_round_trips() { + let request = Request { + id: "popup-close".into(), + method: Method::PopupClose(EmptyParams::default()), + }; + + let json = serde_json::to_value(request).unwrap(); + + assert_eq!(json["method"], "popup.close"); + assert_eq!(json["params"], serde_json::json!({})); +} diff --git a/src/api/server.rs b/src/api/server.rs index aec7274d..c4c76703 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -406,6 +406,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", Method::PaneReleaseAgent(_) => "pane.release_agent", Method::PaneClose(_) => "pane.close", + Method::PopupClose(_) => "popup.close", Method::EventsSubscribe(_) => "events.subscribe", Method::EventsWait(_) => "events.wait", Method::PaneWaitForOutput(_) => "pane.wait_for_output", diff --git a/src/app/api.rs b/src/app/api.rs index d2c2858f..7bc8edf1 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -151,6 +151,15 @@ impl App { } if let AppEvent::PaneDied { pane_id } = &ev { + if self + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == *pane_id) + { + self.close_popup_pane(); + return; + } let previous_toast = self.state.toast.clone(); if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { self.sync_full_lifecycle_authority_detection_pauses(); @@ -1059,6 +1068,13 @@ impl App { return self.handle_pane_send_input(request.id, params) } Method::PaneClose(target) => return self.handle_pane_close(request.id, target), + Method::PopupClose(_) => { + return if self.close_popup_pane() { + responses::encode_success(request.id, ResponseResult::Ok {}) + } else { + responses::encode_error(request.id, "popup_not_open", "no popup is open") + }; + } Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), Method::IntegrationInstall(params) => { return self.handle_integration_install(request.id, params); diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index 92ab4754..c2f17704 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -3,6 +3,7 @@ use crate::api::schema::{ PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, PluginSourceInfo, PluginSourceKind, }; +use crate::popup_size::PopupSize; const PLUGIN_ID_MAX_CHARS: usize = 120; const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; @@ -68,6 +69,10 @@ struct RawPluginManifestPane { platforms: Option>, #[serde(default)] placement: PluginPanePlacement, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, command: Vec, } @@ -392,12 +397,22 @@ fn normalize_manifest_pane( .filter(|description| !description.is_empty()); let platforms = normalize_platforms(pane.platforms)?; let command = normalize_command(pane.command)?; + if pane.placement != PluginPanePlacement::Popup + && (pane.width.is_some() || pane.height.is_some()) + { + return Err(( + "invalid_plugin_pane_size", + "pane width and height are only supported when placement is popup".to_string(), + )); + } Ok(PluginManifestPane { id, title, description, platforms, placement: pane.placement, + width: pane.width, + height: pane.height, command, }) } diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b7f90df9..164d566e 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -349,8 +349,25 @@ impl App { return encode_error(id, code, message); } let placement = params.placement.unwrap_or(pane.placement); + if placement != PluginPanePlacement::Popup + && (params.width.is_some() || params.height.is_some()) + { + return encode_error( + id, + "invalid_params", + "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", + ); + } match placement { - PluginPanePlacement::Overlay => { + PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { if params.workspace_id.is_some() || params.target_pane_id.is_some() || params.direction.is_some() @@ -358,7 +375,7 @@ impl App { return encode_error( id, "invalid_params", - "overlay plugin panes target the active pane", + "overlay and popup plugin panes target the active pane", ); } } @@ -386,6 +403,7 @@ impl App { PluginPanePlacement::Overlay => { self.open_plugin_overlay_pane(id, params, &plugin, pane) } + PluginPanePlacement::Popup => self.open_plugin_popup_pane(id, params, &plugin, pane), PluginPanePlacement::Split | PluginPanePlacement::Zoomed => { self.open_plugin_split_pane(id, params, &plugin, pane, placement) } @@ -659,7 +677,7 @@ fn manifest_actions( mod tests { use super::*; use crate::api::schema::{ - Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, + Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -983,6 +1001,24 @@ platforms = ["linux", "macos", "windows"] "#, "plugin_requires_newer_herdr", ), + ( + "plugin-non-popup-size", + r#" +id = "example.non-popup-size" +name = "Non Popup Size" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[panes]] +id = "board" +title = "Board" +placement = "split" +width = "80%" +command = ["echo", "board"] +"#, + "invalid_plugin_pane_size", + ), ]; for (name, manifest, expected_code) in cases { @@ -1139,6 +1175,8 @@ command = ["echo", "b"] plugin_id: "example.missing".into(), entrypoint: "ui".into(), placement: Some(PluginPanePlacement::Split), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1151,6 +1189,98 @@ command = ["echo", "b"] assert_eq!(value["error"]["code"], "plugin_not_found"); } + #[test] + fn plugin_pane_open_rejects_popup_size_for_non_popup_placement() { + let mut app = test_app(); + let root = unique_temp_path("plugin-pane-non-popup-size-param"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let response = app.handle_api_request(Request { + id: "pane-open-size".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Split), + width: Some(crate::popup_size::PopupSize::Percent(80)), + height: None, + workspace_id: None, + target_pane_id: None, + direction: Some(crate::api::schema::SplitDirection::Right), + cwd: None, + focus: false, + env: std::collections::HashMap::new(), + }), + }); + + let value: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(value["error"]["code"], "invalid_params"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_pane_open_popup_preserves_existing_ui_modes() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let root_pane = app.state.workspaces[0].tabs[0].root_pane; + let root = unique_temp_path("plugin-popup-ui-busy"); + write_manifest(&root); + link_manifest(&mut app, &root); + + let open_popup = |app: &mut App, id: &str| { + app.handle_api_request(Request { + id: id.into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.worktree-bootstrap".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Popup), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }) + }; + + app.state.mode = crate::app::Mode::Settings; + app.state.settings.original_theme = Some("settings-theme".into()); + let settings_response = open_popup(&mut app, "settings-popup"); + let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); + assert_eq!(settings_error["error"]["code"], "ui_busy"); + assert_eq!(app.state.mode, crate::app::Mode::Settings); + assert_eq!( + app.state.settings.original_theme.as_deref(), + Some("settings-theme") + ); + assert!(app.state.popup_pane.is_none()); + + let copy_mode = crate::app::state::CopyModeState { + pane_id: root_pane, + cursor_row: 2, + cursor_col: 3, + entry_offset_from_bottom: 4, + selection: None, + search: crate::app::state::CopyModeSearchState::default(), + }; + app.state.mode = crate::app::Mode::Copy; + app.state.copy_mode = Some(copy_mode.clone()); + let copy_response = open_popup(&mut app, "copy-popup"); + let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); + assert_eq!(copy_error["error"]["code"], "ui_busy"); + assert_eq!(app.state.mode, crate::app::Mode::Copy); + assert_eq!(app.state.copy_mode, Some(copy_mode)); + assert!(app.state.popup_pane.is_none()); + + let _ = std::fs::remove_dir_all(root); + } + #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1200,6 +1330,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \ plugin_id: "example.pane".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1305,6 +1437,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL plugin_id: "example.path-env".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1406,6 +1540,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.tab".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1487,6 +1623,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.split".into(), entrypoint: "board".into(), placement: Some(PluginPanePlacement::Zoomed), + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: Some(crate::api::schema::SplitDirection::Right), @@ -1564,6 +1702,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.overlay".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, @@ -1598,6 +1738,114 @@ command = ["sh", "-c", "sleep 1"] let _ = std::fs::remove_dir_all(root); } + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_popup_is_layout_neutral() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-popup")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + let root_pane = app.state.workspaces[0].tabs[0].root_pane; + let root_public = app.public_pane_id(0, root_pane).unwrap(); + + let root = unique_temp_path("plugin-pane-popup"); + let env_capture = root.join("popup-env.txt"); + let manifest = format!( + r#" +id = "example.popup" +name = "Popup Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Popup" +placement = "popup" +width = "80%" +height = "40%" +command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] +"#, + env_capture.display() + ); + write_manifest_content(&root, &manifest); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open-popup".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.popup".into(), + entrypoint: "board".into(), + placement: None, + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + assert_eq!(response_result(&open), ResponseResult::Ok {}); + assert_eq!( + read_capture_when_ready(&env_capture, || { + app.drain_internal_events(); + }), + "unset" + ); + + let opened_pane_id = app.state.popup_pane.as_ref().unwrap().pane_id; + assert!(!app.state.plugin_panes.contains_key(&opened_pane_id)); + app.state.assert_invariants_for_test(); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); + let (outer, inner) = crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area) + .expect("popup rects"); + assert_eq!((outer.width, outer.height), (80, 12)); + assert_eq!((inner.width, inner.height), (77, 10)); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + + let pane_list = app.handle_api_request(Request { + id: "pane-list-popup".into(), + method: Method::PaneList(PaneListParams { + workspace_id: Some(app.public_workspace_id(0)), + }), + }); + let ResponseResult::PaneList { panes } = response_result(&pane_list) else { + panic!("expected pane list response: {pane_list}"); + }; + assert_eq!(panes.len(), 1); + assert_eq!(panes[0].pane_id, root_public); + assert!(panes[0].focused); + assert_eq!( + app.current_plugin_context("popup-open").focused_pane_id, + Some(root_public) + ); + assert!(event_hub.events_after(0).is_empty()); + + app.handle_internal_event(crate::events::AppEvent::PaneDied { + pane_id: opened_pane_id, + }); + assert!(app.state.popup_pane.is_none()); + assert!(event_hub.events_after(0).is_empty()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + #[test] fn manifest_action_list_and_invoke_with_context() { let mut app = test_app(); @@ -1729,6 +1977,8 @@ command = ["sh", "-c", "sleep 1"] plugin_id: "example.worktree-bootstrap".into(), entrypoint: "board".into(), placement: None, + width: None, + height: None, workspace_id: None, target_pane_id: None, direction: None, diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index b2988998..74a5df23 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -8,6 +8,39 @@ use crate::api::schema::{ use crate::app::App; impl App { + pub(super) fn open_plugin_popup_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let context = self.current_plugin_context("plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let width = params.width.or(pane.width); + let height = params.height.or(pane.height); + if let Err(err) = self.spawn_popup_argv_command( + &pane.command, + cwd, + extra_env, + crate::app::popup::PopupGeometry { width, height }, + ) { + return encode_error(id, "plugin_pane_open_failed", err.to_string()); + } + let Some(popup) = self.state.popup_pane.as_ref() else { + return encode_error(id, "plugin_pane_open_failed", "plugin popup disappeared"); + }; + if let Some(terminal) = self.state.terminals.get_mut(&popup.terminal_id) { + terminal.set_manual_label(pane.title); + } + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn open_plugin_overlay_pane( &mut self, id: String, diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 421b3738..21e9a49b 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -1,6 +1,8 @@ //! Input handling — translates crossterm key/mouse events into state mutations. +use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use tracing::warn; use crate::app::PaneClickState; use crate::input::TerminalKey; @@ -70,6 +72,10 @@ use super::App; impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { + if self.state.popup_pane.is_some() { + self.handle_terminal_key(key).await; + return; + } let key_event = key.as_key_event(); if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) { if let Some(text) = crate::platform::read_clipboard_text() { @@ -111,6 +117,14 @@ impl App { } pub(super) async fn handle_paste(&mut self, text: String) { + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.send_paste(text).await; + } else { + self.close_popup_pane(); + } + return; + } if self.state.mode != Mode::Terminal { self.paste_into_active_text_input(&text); return; @@ -239,6 +253,10 @@ impl App { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.state.popup_pane.is_some() { + self.handle_popup_mouse(mouse); + return; + } if self.handle_overlay_mouse(mouse) { return; } @@ -373,6 +391,62 @@ impl App { } } + fn handle_popup_mouse(&mut self, mouse: MouseEvent) { + let Some((_outer, inner)) = + crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area) + else { + return; + }; + if mouse.column < inner.x + || mouse.column >= inner.x.saturating_add(inner.width) + || mouse.row < inner.y + || mouse.row >= inner.y.saturating_add(inner.height) + { + return; + } + let Some(rt) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let column = mouse.column.saturating_sub(inner.x); + let row = mouse.row.saturating_sub(inner.y); + let bytes = match mouse.kind { + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => match rt.wheel_routing() { + Some(crate::pane::WheelRouting::MouseReport) => { + rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + rt.encode_alternate_scroll(mouse.kind) + } + Some(crate::pane::WheelRouting::HostScroll) | None => { + let lines_per_notch = self.state.mouse_scroll_lines; + match mouse.kind { + MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), + MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), + _ => {} + } + return; + } + }, + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) + } + MouseEventKind::Moved => { + rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) + } + }; + let Some(bytes) = bytes else { + return; + }; + rt.scroll_reset(); + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event"); + } + } + fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool { if self.state.mode != Mode::Terminal || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 7f98f530..33177624 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -778,6 +778,7 @@ impl App { crate::config::CustomCommandAction::Pane => { self.spawn_pane_command(&binding.command, Vec::new()) } + crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding), crate::config::CustomCommandAction::PluginAction => self .invoke_plugin_action_from_keybind(binding.command.clone()) .map_err(std::io::Error::other), @@ -798,6 +799,21 @@ impl App { } } + 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, + }, + ) + } + fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { let mut env = vec![( crate::api::SOCKET_PATH_ENV_VAR.to_string(), @@ -2956,6 +2972,8 @@ navigate_pane_down = "ctrl+j" command, action: crate::config::CustomCommandAction::Shell, description: None, + width: None, + height: None, }]; app.handle_key(TerminalKey::new( @@ -3048,6 +3066,8 @@ navigate_pane_down = "ctrl+j" command, action: crate::config::CustomCommandAction::Pane, description: None, + width: None, + height: None, }]; app.handle_key(TerminalKey::new( diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index dd1e811f..e8e6495e 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -13,12 +13,31 @@ struct PreparedPaneInput { bytes: Bytes, } +enum PreparedPopupInput { + NotOpen, + Consumed, + Bytes(Bytes), +} + fn is_modifier_only_key(code: &KeyCode) -> bool { matches!(code, KeyCode::Modifier(_)) } impl App { pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) { + match self.prepare_popup_key_forward(key) { + PreparedPopupInput::NotOpen => {} + PreparedPopupInput::Consumed => return, + PreparedPopupInput::Bytes(bytes) => { + let Some(runtime) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let _ = runtime.try_send_bytes(bytes); + return; + } + } + let Some(input) = self.prepare_terminal_key_forward(key) else { return; }; @@ -192,7 +211,38 @@ impl App { }) } + fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput { + if self.state.popup_pane.is_none() { + return PreparedPopupInput::NotOpen; + } + let Some(rt) = self.popup_runtime() else { + self.close_popup_pane(); + return PreparedPopupInput::Consumed; + }; + rt.scroll_reset(); + let bytes = rt.encode_terminal_key(key); + self.state.mode = Mode::Terminal; + if bytes.is_empty() { + PreparedPopupInput::Consumed + } else { + PreparedPopupInput::Bytes(Bytes::from(bytes)) + } + } + pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) { + match self.prepare_popup_key_forward(key) { + PreparedPopupInput::NotOpen => {} + PreparedPopupInput::Consumed => return, + PreparedPopupInput::Bytes(bytes) => { + let Some(runtime) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let _ = runtime.send_bytes(bytes).await; + return; + } + } + let Some(input) = self.prepare_terminal_key_forward(key) else { return; }; @@ -213,6 +263,45 @@ mod tests { use super::*; use crate::{config::Config, events::AppEvent, workspace::Workspace}; + #[cfg(unix)] + fn app_with_spawned_workspace() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.default_shell = "/bin/sh".into(); + let (workspace, terminal, runtime) = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.terminal_runtimes.insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app + } + + #[cfg(unix)] + fn shutdown_test_runtimes(app: &mut App) { + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); @@ -1093,6 +1182,8 @@ mod tests { command, action: crate::config::CustomCommandAction::Shell, description: None, + width: None, + height: None, }]; app.handle_terminal_key(TerminalKey::new( @@ -1109,33 +1200,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn direct_custom_pane_command_opens_overlay_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.default_shell = "/usr/bin/true".into(); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; + let mut app = app_with_spawned_workspace(); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), @@ -1143,6 +1208,8 @@ mod tests { command: "printf direct-pane".into(), action: crate::config::CustomCommandAction::Pane, description: None, + width: None, + height: None, }]; app.handle_terminal_key(TerminalKey::new( @@ -1155,10 +1222,157 @@ mod tests { assert!(app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); + shutdown_test_runtimes(&mut app); + } + + #[cfg(unix)] + #[tokio::test] + async fn direct_custom_popup_command_opens_layout_neutral_popup() { + let mut app = app_with_spawned_workspace(); + + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), + label: "ctrl+alt+g".into(), + command: "sleep 1".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: Some(crate::popup_size::PopupSize::Cells(60)), + height: Some(crate::popup_size::PopupSize::Cells(12)), + }]; + + app.handle_terminal_key(TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + .await; + + assert!(app.state.popup_pane.is_some()); + assert!(!app + .popup_runtime() + .unwrap() + .agent_detection_enabled_for_test()); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + assert_eq!(app.state.mode, Mode::Terminal); + let snapshot = crate::persist::capture( + &app.state.workspaces, + &app.state.terminals, + &app.terminal_runtimes, + app.state.active, + app.state.selected, + app.state.sidebar_width, + app.state.sidebar_section_split, + app.state.collapsed_space_keys.clone(), + ); + assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1); + assert!(matches!( + snapshot.workspaces[0].tabs[0].layout, + crate::persist::LayoutSnapshot::Pane(_) + )); + + shutdown_test_runtimes(&mut app); + } + + #[cfg(unix)] + #[tokio::test] + async fn direct_custom_popup_command_closes_after_exit() { + let mut app = app_with_spawned_workspace(); + let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap(); + let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap(); + + let output_path = unique_temp_path("custom-popup-command"); + let command = format!( + "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", + output_path.display() + ); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), + label: "ctrl+alt+g".into(), + command, + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }]; + + app.handle_terminal_key(TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + .await; + + assert_eq!( + wait_for_file(&output_path), + format!("unset|{focused_pane_id}") + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + app.drain_internal_events(); + if app.state.popup_pane.is_none() { + break; + } } + + assert!(app.state.popup_pane.is_none()); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_file(output_path); + } + + #[tokio::test] + async fn popup_forwards_escape_instead_of_closing() { + let mut app = app_for_mouse_test(); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 2, + 1024, + b"one\r\ntwo\r\nthree\r\n", + 4, + ); + runtime.scroll_up(1); + assert!(runtime + .scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0)); + app.install_test_popup_runtime(runtime); + app.state.mode = Mode::Settings; + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + + assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b"); + assert!(app.state.popup_pane.is_some()); + assert_eq!( + app.popup_runtime() + .and_then(crate::terminal::TerminalRuntime::scroll_metrics) + .map(|metrics| metrics.offset_from_bottom), + Some(0) + ); + } + + #[tokio::test] + async fn local_popup_input_waits_for_channel_capacity() { + let mut app = app_for_mouse_test(); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1); + runtime + .try_send_bytes(Bytes::from_static(b"queued")) + .unwrap(); + app.install_test_popup_runtime(runtime); + app.state.mode = Mode::Settings; + + let mut send = Box::pin( + app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())), + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut send) + .await + .is_err() + ); + + assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued"); + send.await; + assert_eq!(rx.recv().await.unwrap().as_ref(), b"x"); } #[tokio::test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 7391d7d3..a77f2536 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,6 +13,7 @@ mod config_io; mod creation; mod ids; mod input; +mod popup; mod runtime; mod runtime_mutations; mod session; @@ -659,6 +660,7 @@ impl App { pane_graphics_layers: std::collections::HashMap::new(), pane_graphics_streams: std::collections::HashMap::new(), pane_graphics_revision: 0, + popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, @@ -1577,7 +1579,8 @@ impl App { let key_id = repeat_key_identity(&key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.mode == Mode::Terminal { + if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal + { self.suppressed_repeat_keys.remove(&key_id); self.handle_terminal_key_headless(key); } else { @@ -1586,7 +1589,8 @@ impl App { } } crossterm::event::KeyEventKind::Repeat => { - if self.state.mode == Mode::Terminal + if (self.state.popup_pane.is_some() + || self.state.mode == Mode::Terminal) && !self.suppressed_repeat_keys.contains(&key_id) { self.handle_terminal_key_headless(key); @@ -1600,7 +1604,7 @@ impl App { } } crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.mouse_capture { + if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse_event_headless(mouse); } else { self.state @@ -1608,7 +1612,8 @@ impl App { } } crate::raw_input::RawInputEvent::Paste(text) => { - if self.state.mode != Mode::Terminal { + if self.try_route_paste_to_popup(&text) { + } else if self.state.mode != Mode::Terminal { self.paste_into_active_text_input(&text); } else { if let Some(ws_idx) = self.state.active { @@ -1619,17 +1624,7 @@ impl App { ws_idx, focused, ) { - let _ = runtime.try_send_bytes(bytes::Bytes::from( - if runtime - .input_state() - .map(|s| s.bracketed_paste) - .unwrap_or(false) - { - format!("\x1b[200~{text}\x1b[201~") - } else { - text - }, - )); + let _ = runtime.try_send_paste(text); } } } @@ -4918,6 +4913,168 @@ last_pane = "prefix+tab" ); } + #[tokio::test] + async fn route_client_events_pastes_only_into_popup() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("tiled"); + let focused = workspace.focused_pane_id().unwrap(); + let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, tiled_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); + app.install_test_popup_runtime(popup_runtime); + assert!(app + .state + .should_capture_host_mouse_from(&app.terminal_runtimes)); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())], + true, + ); + + assert_eq!( + popup_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"popup-only") + ); + assert!(tiled_rx.try_recv().is_err()); + + app.route_client_events( + vec![raw_key( + KeyCode::Char('x'), + KeyModifiers::NONE, + KeyEventKind::Press, + )], + true, + ); + assert_eq!( + popup_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"x") + ); + assert!(tiled_rx.try_recv().is_err()); + + app.state.mode = Mode::Settings; + assert!( + app.handle_raw_input_event(raw_key( + KeyCode::Char('y'), + KeyModifiers::NONE, + KeyEventKind::Repeat, + )) + .await + ); + assert_eq!( + popup_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"y") + ); + assert!(tiled_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn route_client_events_discards_paste_when_popup_runtime_is_missing() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("tiled"); + let focused = workspace.focused_pane_id().unwrap(); + let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, tiled_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let install_missing_popup = |app: &mut App| { + let popup_terminal_id = crate::terminal::TerminalId::alloc(); + app.state.terminals.insert( + popup_terminal_id.clone(), + crate::terminal::TerminalState::new( + popup_terminal_id.clone(), + std::path::PathBuf::from("/popup"), + ), + ); + app.state.popup_pane = Some(state::PopupPaneState { + pane_id: crate::layout::PaneId::alloc(), + terminal_id: popup_terminal_id, + width: None, + height: None, + }); + }; + install_missing_popup(&mut app); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())], + true, + ); + + assert!(tiled_rx.try_recv().is_err()); + assert!(app.state.popup_pane.is_none()); + + install_missing_popup(&mut app); + assert!( + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste( + "discard-monolithic".into(), + )) + .await + ); + assert!(tiled_rx.try_recv().is_err()); + assert!(app.state.popup_pane.is_none()); + } + + #[tokio::test] + async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("tiled"); + let focused = workspace.focused_pane_id().unwrap(); + let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); + tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); + workspace.tabs[0].runtimes.insert(focused, tiled_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.mouse_capture = false; + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); + + let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); + popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); + app.install_test_popup_runtime(popup_runtime); + let (_, inner) = + crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Mouse( + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down( + crossterm::event::MouseButton::Left, + ), + column: inner.x, + row: inner.y, + modifiers: crossterm::event::KeyModifiers::NONE, + }, + )], + true, + ); + + assert!(popup_rx.try_recv().is_ok()); + assert!(tiled_rx.try_recv().is_err()); + + assert!( + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse( + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down( + crossterm::event::MouseButton::Left, + ), + column: inner.x + 1, + row: inner.y, + modifiers: crossterm::event::KeyModifiers::NONE, + }, + )) + .await + ); + assert!(popup_rx.try_recv().is_ok()); + assert!(tiled_rx.try_recv().is_err()); + } + #[test] fn route_client_input_closes_release_notes_modal() { let mut app = test_app(); diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 00000000..3aeef35c --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,309 @@ +use std::path::PathBuf; + +use crate::app::{App, Mode}; +use crate::layout::PaneId; +use crate::pane::PaneLaunchEnv; +use crate::popup_size::{resolve_popup_geometry, PopupSize}; +use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct PopupGeometry { + pub width: Option, + pub height: Option, +} + +impl App { + pub(crate) fn popup_runtime(&self) -> Option<&TerminalRuntime> { + let terminal_id = &self.state.popup_pane.as_ref()?.terminal_id; + self.terminal_runtimes.get(terminal_id) + } + + pub(crate) fn close_popup_pane(&mut self) -> bool { + let Some(popup) = self.state.popup_pane.take() else { + return false; + }; + self.state + .direct_attach_resize_locks + .remove(&popup.terminal_id); + self.state.terminals.remove(&popup.terminal_id); + if let Some(runtime) = self.terminal_runtimes.remove(&popup.terminal_id) { + runtime.shutdown(); + } + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + self.render_dirty + .store(true, std::sync::atomic::Ordering::Release); + self.render_notify.notify_one(); + 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, + cwd: Option, + extra_env: Vec<(String, String)>, + geometry: PopupGeometry, + ) -> std::io::Result<()> { + self.spawn_popup_command( + cwd, + extra_env, + geometry, + |pane_id, rows, cols, cwd, launch_env, app| { + TerminalRuntime::spawn_shell_command( + pane_id, + rows, + cols, + cwd, + command, + launch_env, + crate::pane::AgentDetection::Disabled, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .map(|runtime| (runtime, None)) + }, + ) + } + + pub(crate) fn spawn_popup_argv_command( + &mut self, + argv: &[String], + cwd: Option, + extra_env: Vec<(String, String)>, + geometry: PopupGeometry, + ) -> std::io::Result<()> { + self.spawn_popup_command( + cwd, + extra_env, + geometry, + |pane_id, rows, cols, cwd, launch_env, app| { + TerminalRuntime::spawn_argv_command( + pane_id, + rows, + cols, + cwd, + argv, + launch_env, + crate::pane::AgentDetection::Disabled, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .map(|runtime| (runtime, Some(argv.to_vec()))) + }, + ) + } + + fn spawn_popup_command( + &mut self, + cwd: Option, + extra_env: Vec<(String, String)>, + geometry: PopupGeometry, + spawn: F, + ) -> std::io::Result<()> + where + F: FnOnce( + PaneId, + u16, + u16, + PathBuf, + &PaneLaunchEnv, + &mut App, + ) -> std::io::Result<(TerminalRuntime, Option>)>, + { + if self.state.popup_pane.is_some() { + return Err(std::io::Error::other("popup already open")); + } + let Some(ws_idx) = self.state.active else { + return Err(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 active_tab = ws + .active_tab() + .ok_or_else(|| std::io::Error::other("active tab disappeared"))?; + let focused_pane = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("active tab has no focused pane"))?; + let cwd = cwd.or_else(|| { + active_tab.cwd_for_pane(focused_pane, &self.state.terminals, &self.terminal_runtimes) + }); + let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let pane_id = PaneId::alloc(); + let terminal_id = TerminalId::alloc(); + let launch_env = PaneLaunchEnv::from_extra(extra_env).without_pane_identity(); + let terminal_area = if self.state.view.terminal_area.width >= 4 + && self.state.view.terminal_area.height >= 4 + { + self.state.view.terminal_area + } else { + let (estimated_rows, estimated_cols) = self.state.estimate_pane_size(); + ratatui::layout::Rect::new(0, 0, estimated_cols, estimated_rows) + }; + let Some(resolved_geometry) = + resolve_popup_geometry(geometry.width, geometry.height, terminal_area) + else { + return Err(std::io::Error::other("terminal area too small for popup")); + }; + let rows = resolved_geometry.inner.height; + let cols = resolved_geometry.inner.width; + let (runtime, launch_argv) = spawn(pane_id, rows, cols, cwd.clone(), &launch_env, self)?; + let terminal = match launch_argv { + Some(argv) => TerminalState::new(terminal_id.clone(), cwd).with_launch_argv(argv), + None => TerminalState::new(terminal_id.clone(), cwd), + }; + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + self.state.terminals.insert(terminal_id.clone(), terminal); + self.state.popup_pane = Some(crate::app::state::PopupPaneState { + pane_id, + terminal_id, + width: geometry.width, + height: geometry.height, + }); + self.state.mode = Mode::Terminal; + Ok(()) + } +} + +#[cfg(test)] +impl App { + pub(crate) fn install_test_popup_runtime( + &mut self, + runtime: TerminalRuntime, + ) -> (PaneId, TerminalId) { + let pane_id = PaneId::alloc(); + let terminal_id = TerminalId::alloc(); + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + self.state.terminals.insert( + terminal_id.clone(), + TerminalState::new(terminal_id.clone(), PathBuf::from("/popup")), + ); + self.state.popup_pane = Some(crate::app::state::PopupPaneState { + pane_id, + terminal_id: terminal_id.clone(), + width: None, + height: None, + }); + (pane_id, terminal_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_popup() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("popup")]; + app.state.active = Some(0); + app.state.selected = 0; + let terminal_id = TerminalId::alloc(); + app.state.terminals.insert( + terminal_id.clone(), + TerminalState::new(terminal_id.clone(), PathBuf::from("/popup")), + ); + app.state.popup_pane = Some(crate::app::state::PopupPaneState { + pane_id: PaneId::alloc(), + terminal_id, + width: None, + height: None, + }); + app + } + + #[test] + fn close_popup_uses_terminal_mode_with_active_workspace() { + let mut app = app_with_popup(); + app.state.mode = Mode::Navigate; + + assert!(app.close_popup_pane()); + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn close_popup_uses_navigate_mode_without_active_workspace() { + let mut app = app_with_popup(); + app.state.workspaces.clear(); + app.state.active = None; + app.state.mode = Mode::Navigate; + + assert!(app.close_popup_pane()); + + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn close_popup_clears_direct_attach_resize_lock() { + let mut app = app_with_popup(); + let terminal_id = app.state.popup_pane.as_ref().unwrap().terminal_id.clone(); + app.state + .direct_attach_resize_locks + .insert(terminal_id.clone()); + + assert!(app.close_popup_pane()); + + assert!(!app.state.direct_attach_resize_locks.contains(&terminal_id)); + } + + #[test] + fn popup_survives_background_workspace_removal() { + let mut app = app_with_popup(); + app.state.workspaces.clear(); + app.state.active = None; + + app.state.assert_invariants_for_test(); + + assert!(app.state.popup_pane.is_some()); + } + + #[test] + fn popup_close_api_closes_only_active_popup() { + let mut app = app_with_popup(); + let close = || crate::api::schema::Request { + id: "close-popup".into(), + method: crate::api::schema::Method::PopupClose( + crate::api::schema::EmptyParams::default(), + ), + }; + + let response = app.handle_api_request(close()); + let response: crate::api::schema::SuccessResponse = + serde_json::from_str(&response).unwrap(); + assert_eq!(response.result, crate::api::schema::ResponseResult::Ok {}); + + let response = app.handle_api_request(close()); + let response: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.error.code, "popup_not_open"); + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 6d63f139..cad49372 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -142,7 +142,7 @@ impl App { let key_id = repeat_key_identity(&key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.mode == Mode::Terminal { + if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal { self.suppressed_repeat_keys.remove(&key_id); } else { self.suppressed_repeat_keys.insert(key_id); @@ -151,7 +151,7 @@ impl App { true } crossterm::event::KeyEventKind::Repeat => { - if self.state.mode == Mode::Terminal + if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal) && !self.suppressed_repeat_keys.contains(&key_id) { self.handle_key(key).await; @@ -171,7 +171,7 @@ impl App { true } crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.mouse_capture { + if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse(mouse); } else { self.state diff --git a/src/app/state.rs b/src/app/state.rs index c0f2c8bb..d821407b 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -53,6 +53,14 @@ fn pane_graphics_data_fingerprint(data: &[u8]) -> u64 { hasher.finish() } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PopupPaneState { + pub pane_id: PaneId, + pub terminal_id: crate::terminal::TerminalId, + pub width: Option, + pub height: Option, +} + // --------------------------------------------------------------------------- // Selection autoscroll types // --------------------------------------------------------------------------- @@ -1515,6 +1523,8 @@ pub struct AppState { pub(crate) pane_graphics_streams: std::collections::HashMap, /// Monotonic marker for accepted pane graphics mutations. pub(crate) pane_graphics_revision: u64, + /// Session-modal terminal popup. This is intentionally outside workspace layouts. + pub(crate) popup_pane: Option, /// Recent plugin action/event command executions. pub(crate) plugin_command_logs: Vec, pub(crate) next_plugin_command_log_id: u64, @@ -1608,7 +1618,9 @@ impl AppState { &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> bool { - self.mouse_capture || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) + self.mouse_capture + || self.popup_pane.is_some() + || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) } pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { @@ -1874,6 +1886,7 @@ impl AppState { pane_graphics_layers: std::collections::HashMap::new(), pane_graphics_streams: std::collections::HashMap::new(), pane_graphics_revision: 0, + popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, @@ -2104,6 +2117,19 @@ impl AppState { "pending agent notification", ); } + if let Some(popup) = &self.popup_pane { + assert!( + self.terminals.contains_key(&popup.terminal_id), + "popup {:?} references missing terminal {}", + popup.pane_id, + popup.terminal_id + ); + assert!( + !attached_terminal_ids.contains(&popup.terminal_id), + "popup terminal {} must not be attached to a tiled pane", + popup.terminal_id + ); + } for &pane_id in self.plugin_panes.keys() { assert_live_pane(pane_id, "plugin pane record"); } diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 261540c7..5cc528f4 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -12,6 +12,7 @@ use crate::api::schema::{ PluginPlatform, PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, Request, ResponseResult, SplitDirection, SuccessResponse, }; +use crate::popup_size::PopupSize; const PLUGIN_BUILD_OUTPUT_MAX_BYTES: usize = 64 * 1024; @@ -487,6 +488,8 @@ fn plugin_pane_open(args: &[String]) -> std::io::Result { let mut plugin_id = None; let mut entrypoint = None; let mut placement = None; + let mut width = None; + let mut height = None; let mut workspace_id = None; let mut target_pane_id = None; let mut direction = None; @@ -518,6 +521,24 @@ fn plugin_pane_open(args: &[String]) -> std::io::Result { }; placement = Some(parsed); } + "--width" => { + let Some(value) = required_value(args, &mut index, "--width") else { + return Ok(2); + }; + let Some(parsed) = parse_popup_dimension(&value, "--width") else { + return Ok(2); + }; + width = Some(parsed); + } + "--height" => { + let Some(value) = required_value(args, &mut index, "--height") else { + return Ok(2); + }; + let Some(parsed) = parse_popup_dimension(&value, "--height") else { + return Ok(2); + }; + height = Some(parsed); + } "--workspace" => { let Some(value) = required_value(args, &mut index, "--workspace") else { return Ok(2); @@ -586,6 +607,8 @@ fn plugin_pane_open(args: &[String]) -> std::io::Result { plugin_id, entrypoint, placement, + width, + height, workspace_id, target_pane_id, direction, @@ -595,6 +618,16 @@ fn plugin_pane_open(args: &[String]) -> std::io::Result { })) } +fn parse_popup_dimension(value: &str, flag: &str) -> Option { + match PopupSize::parse_cli(value) { + Ok(value) => Some(value), + Err(message) => { + eprintln!("{flag} {message}"); + None + } + } +} + fn plugin_pane_focus(args: &[String]) -> std::io::Result { let Some(pane_id) = args.first() else { eprintln!("usage: herdr plugin pane focus "); @@ -635,6 +668,7 @@ fn required_value(args: &[String], index: &mut usize, flag: &str) -> Option Option { match value { "overlay" => Some(PluginPanePlacement::Overlay), + "popup" => Some(PluginPanePlacement::Popup), "split" => Some(PluginPanePlacement::Split), "tab" => Some(PluginPanePlacement::Tab), "zoomed" | "fullscreen" => Some(PluginPanePlacement::Zoomed), @@ -1604,7 +1638,7 @@ fn print_plugin_action_help() { fn print_plugin_pane_help() { eprintln!("herdr plugin pane commands:"); - eprintln!(" herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|split|tab|zoomed] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus]"); + eprintln!(" herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus]"); eprintln!(" herdr plugin pane focus "); eprintln!(" herdr plugin pane close "); } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 838481ed..29b53355 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -6,6 +6,7 @@ use tracing::warn; use super::Config; use crate::input::TerminalKey; +use crate::popup_size::PopupSize; pub type KeyCombo = (KeyCode, KeyModifiers); @@ -80,6 +81,7 @@ pub enum CommandKeybindType { #[default] Shell, Pane, + Popup, PluginAction, } @@ -95,6 +97,10 @@ pub struct CommandKeybindConfig { pub action_type: CommandKeybindType, /// Optional user-defined description for this custom command. pub description: Option, + /// Optional popup width as cells or a percentage string when type = "popup". + pub width: Option, + /// Optional popup height as cells or a percentage string when type = "popup". + pub height: Option, } impl Default for CommandKeybindConfig { @@ -104,6 +110,8 @@ impl Default for CommandKeybindConfig { command: String::new(), action_type: CommandKeybindType::Shell, description: None, + width: None, + height: None, } } } @@ -112,6 +120,7 @@ impl Default for CommandKeybindConfig { pub enum CustomCommandAction { Shell, Pane, + Popup, PluginAction, } @@ -283,6 +292,8 @@ pub struct CustomCommandKeybind { pub command: String, pub action: CustomCommandAction, pub description: Option, + pub width: Option, + pub height: Option, } /// Parsed keybinds for Herdr actions. @@ -743,8 +754,21 @@ fn append_custom_command_bindings( let action = match command.action_type { CommandKeybindType::Shell => CustomCommandAction::Shell, CommandKeybindType::Pane => CustomCommandAction::Pane, + CommandKeybindType::Popup => CustomCommandAction::Popup, CommandKeybindType::PluginAction => CustomCommandAction::PluginAction, }; + let (width, height) = if action == CustomCommandAction::Popup { + (command.width, command.height) + } else { + if command.width.is_some() || command.height.is_some() { + let diag = format!( + "popup size on non-popup custom command: keys.command[{index}]; ignoring width and height" + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + (None, None) + }; let label = bindings.label().unwrap_or_else(|| "unset".to_string()); keybinds.custom_commands.push(CustomCommandKeybind { bindings, @@ -752,6 +776,8 @@ fn append_custom_command_bindings( command: command.command.clone(), action, description: command.description.clone(), + width, + height, }); } } @@ -2185,4 +2211,54 @@ description = "say hello" Some("say hello".to_string()) ); } + + #[test] + fn custom_popup_command_parses() { + let config: Config = toml::from_str( + r#" +[[keys.command]] +key = "prefix+g" +command = "lazygit" +type = "popup" +width = 90 +height = "80%" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + assert_eq!(keybinds.custom_commands.len(), 1); + assert_eq!( + keybinds.custom_commands[0].action, + CustomCommandAction::Popup + ); + assert_eq!( + keybinds.custom_commands[0].width, + Some(PopupSize::Cells(90)) + ); + assert_eq!( + keybinds.custom_commands[0].height, + Some(PopupSize::Percent(80)) + ); + } + + #[test] + fn non_popup_custom_command_ignores_popup_size_with_diagnostic() { + let config: Config = toml::from_str( + r#" +[[keys.command]] +key = "prefix+g" +command = "lazygit" +type = "pane" +width = "80%" +"#, + ) + .unwrap(); + + let keybinds = config.keybinds(); + assert_eq!(keybinds.custom_commands[0].width, None); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("popup size on non-popup custom command"))); + } } diff --git a/src/main.rs b/src/main.rs index 68e47401..35c7b860 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,7 @@ mod persist; mod platform; mod plugin_command; mod plugin_paths; +mod popup_size; mod product_announcements; mod protocol; mod pty; diff --git a/src/pane.rs b/src/pane.rs index 8f71031f..5bb6fe4e 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -65,21 +65,26 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct PaneLaunchEnv { extra: Vec<(String, String)>, - identity: Option, + identity: PaneLaunchIdentity, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct PaneLaunchIdentity { - workspace_id: String, - tab_id: String, - pane_id: String, +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum PaneLaunchIdentity { + #[default] + Inherit, + Managed { + workspace_id: String, + tab_id: String, + pane_id: String, + }, + OmitPane, } impl PaneLaunchEnv { pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self { Self { extra, - identity: None, + identity: PaneLaunchIdentity::Inherit, } } @@ -89,11 +94,16 @@ impl PaneLaunchEnv { tab_id: String, pane_id: String, ) -> Self { - self.identity = Some(PaneLaunchIdentity { + self.identity = PaneLaunchIdentity::Managed { workspace_id, tab_id, pane_id, - }); + }; + self + } + + pub(crate) fn without_pane_identity(mut self) -> Self { + self.identity = PaneLaunchIdentity::OmitPane; self } } @@ -104,13 +114,20 @@ fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { } cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); crate::integration::apply_pane_base_env(cmd); - if let Some(identity) = &launch_env.identity { - cmd.env( - crate::integration::HERDR_WORKSPACE_ID_ENV_VAR, - &identity.workspace_id, - ); - cmd.env(crate::integration::HERDR_TAB_ID_ENV_VAR, &identity.tab_id); - cmd.env(crate::integration::HERDR_PANE_ID_ENV_VAR, &identity.pane_id); + match &launch_env.identity { + PaneLaunchIdentity::Inherit => {} + PaneLaunchIdentity::Managed { + workspace_id, + tab_id, + pane_id, + } => { + cmd.env(crate::integration::HERDR_WORKSPACE_ID_ENV_VAR, workspace_id); + cmd.env(crate::integration::HERDR_TAB_ID_ENV_VAR, tab_id); + cmd.env(crate::integration::HERDR_PANE_ID_ENV_VAR, pane_id); + } + PaneLaunchIdentity::OmitPane => { + cmd.env_remove(crate::integration::HERDR_PANE_ID_ENV_VAR); + } } } @@ -127,6 +144,12 @@ struct SpawnInitialState<'a> { windows_powershell_prompt_cwd_reporting: bool, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentDetection { + Enabled, + Disabled, +} + fn active_pending_release( pending_release: &Mutex>, now: std::time::Instant, @@ -917,7 +940,7 @@ pub struct PaneRuntime { pending_release: Arc>>, preserve_processes_on_drop: bool, // Task handles for deterministic shutdown - detect_handle: tokio::task::AbortHandle, + detect_handle: Option, } enum PaneRuntimeIo { @@ -1061,7 +1084,9 @@ impl Drop for PaneRuntime { fn drop(&mut self) { // Abort detection task immediately and terminate the owned session. // The PTY actor shuts down before the process/session policy runs. - self.detect_handle.abort(); + if let Some(handle) = &self.detect_handle { + handle.abort(); + } self.io.shutdown(); if !self.preserve_processes_on_drop { shutdown_pane_processes( @@ -1411,7 +1436,9 @@ fn publish_reported_cwd( impl PaneRuntime { pub fn shutdown(mut self) { - self.detect_handle.abort(); + if let Some(handle) = self.detect_handle.take() { + handle.abort(); + } self.io.shutdown(); shutdown_pane_processes( self.pane_id, @@ -1435,7 +1462,9 @@ impl PaneRuntime { "failed to release PTY actor after handoff commit; dropping runtime will still close the actor handle" ); } - self.detect_handle.abort(); + if let Some(handle) = self.detect_handle.take() { + handle.abort(); + } self.preserve_processes_on_drop = true; } @@ -1571,6 +1600,7 @@ impl PaneRuntime { history_ansi: initial_history_ansi, windows_powershell_prompt_cwd_reporting, }, + AgentDetection::Enabled, ) } @@ -1583,6 +1613,7 @@ impl PaneRuntime { cwd: std::path::PathBuf, command: &str, launch_env: &PaneLaunchEnv, + agent_detection: AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, @@ -1605,9 +1636,12 @@ impl PaneRuntime { cmd, "failed to spawn command pane", SpawnInitialState::default(), + agent_detection, ) } + // Runtime construction needs to thread PTY size, environment, theme, render hooks, and detection policy together. + #[allow(clippy::too_many_arguments)] pub fn spawn_argv_command( pane_id: PaneId, rows: u16, @@ -1615,6 +1649,7 @@ impl PaneRuntime { cwd: std::path::PathBuf, argv: &[String], launch_env: &PaneLaunchEnv, + agent_detection: AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, @@ -1646,6 +1681,7 @@ impl PaneRuntime { cmd, "failed to spawn argv command pane", SpawnInitialState::default(), + agent_detection, ) } @@ -1791,10 +1827,12 @@ impl PaneRuntime { detect_reset_notify, pending_release, preserve_processes_on_drop: true, - detect_handle, + detect_handle: Some(detect_handle), }) } + // Runtime construction needs to thread PTY size, environment, theme, render hooks, and detection policy together. + #[allow(clippy::too_many_arguments)] fn spawn_command_builder( pane_id: PaneId, rows: u16, @@ -1807,6 +1845,7 @@ impl PaneRuntime { cmd: CommandBuilder, spawn_error_message: &'static str, initial_state: SpawnInitialState<'_>, + agent_detection: AgentDetection, ) -> std::io::Result { crate::logging::pane_spawn_started(pane_id.raw(), rows, cols, scrollback_limit_bytes); @@ -1881,7 +1920,9 @@ impl PaneRuntime { let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); - observe_detection_content_change(bytes, &detection_content_seq); + if agent_detection == AgentDetection::Enabled { + observe_detection_content_change(bytes, &detection_content_seq); + } if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { render_notify.notify_one(); } @@ -1924,7 +1965,9 @@ impl PaneRuntime { }; // --- Detection task --- - let (detect_handle, detect_reset_notify, pending_release) = { + let (detect_handle, detect_reset_notify, pending_release) = if agent_detection + == AgentDetection::Enabled + { use crate::detect; use std::time::{Duration, Instant}; @@ -2282,7 +2325,13 @@ impl PaneRuntime { } } }); - (handle.abort_handle(), detect_reset_notify, pending_release) + ( + Some(handle.abort_handle()), + detect_reset_notify, + pending_release, + ) + } else { + (None, Arc::new(Notify::new()), Arc::new(Mutex::new(None))) }; Ok(Self { @@ -2322,6 +2371,11 @@ impl PaneRuntime { self.detect_reset_notify.clone() } + #[cfg(test)] + pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { + self.detect_handle.is_some() + } + pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -2538,6 +2592,14 @@ impl PaneRuntime { } pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError> { + self.send_bytes(self.paste_payload(text)).await + } + + pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { + self.try_send_bytes(self.paste_payload(text)) + } + + fn paste_payload(&self, text: String) -> Bytes { let bracketed = self .input_state() .map(|state| state.bracketed_paste) @@ -2547,7 +2609,7 @@ impl PaneRuntime { } else { text }; - self.send_bytes(Bytes::from(payload)).await + Bytes::from(payload) } pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { @@ -2743,7 +2805,7 @@ impl PaneRuntime { detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, - detect_handle: tokio::spawn(async {}).abort_handle(), + detect_handle: Some(tokio::spawn(async {}).abort_handle()), }, rx, ) @@ -3205,7 +3267,7 @@ mod tests { detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, - detect_handle: tokio::spawn(async {}).abort_handle(), + detect_handle: Some(tokio::spawn(async {}).abort_handle()), }; assert!(runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); @@ -3236,7 +3298,7 @@ mod tests { detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, - detect_handle: tokio::spawn(async {}).abort_handle(), + detect_handle: Some(tokio::spawn(async {}).abort_handle()), }; assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); diff --git a/src/popup_size.rs b/src/popup_size.rs new file mode 100644 index 00000000..c1cc40eb --- /dev/null +++ b/src/popup_size.rs @@ -0,0 +1,252 @@ +use std::borrow::Cow; + +use ratatui::layout::Rect; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PopupSize { + Cells(u16), + Percent(u8), +} + +impl PopupSize { + pub(crate) fn resolve(self, available: u16) -> u16 { + match self { + Self::Cells(cells) => cells, + Self::Percent(percent) => ((available as u32 * percent as u32) / 100) as u16, + } + } + + pub(crate) fn parse_cli(value: &str) -> Result { + if let Some(percent) = value.strip_suffix('%') { + let percent = percent + .parse::() + .map_err(|_| "must be a number of cells or a percentage like 80%".to_string())?; + if !(1..=100).contains(&percent) { + return Err("percentage must be between 1% and 100%".to_string()); + } + return Ok(Self::Percent(percent)); + } + value + .parse::() + .map(Self::Cells) + .map_err(|_| "must be a number of cells or a percentage like 80%".to_string()) + } + + fn parse_percent_string(value: &str) -> Result { + if value.ends_with('%') { + return Self::parse_cli(value); + } + Err("string sizes must be percentages like 80%; use a number for cells".to_string()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PopupResolvedGeometry { + pub outer: Rect, + pub inner: Rect, +} + +pub(crate) fn resolve_popup_geometry( + width: Option, + height: Option, + area: Rect, +) -> Option { + let default_width = area.width.saturating_div(2).max(6); + let default_height = area.height.saturating_div(2).max(4); + let outer_width = width + .map(|width| width.resolve(area.width)) + .unwrap_or(default_width) + .max(6) + .min(area.width); + let outer_height = height + .map(|height| height.resolve(area.height)) + .unwrap_or(default_height) + .max(4) + .min(area.height); + if outer_width < 6 || outer_height < 4 { + return None; + } + + let outer_x = area.x + (area.width.saturating_sub(outer_width)) / 2; + let outer_y = area.y + (area.height.saturating_sub(outer_height)) / 2; + let pane_inner_width = outer_width.saturating_sub(2); + let pane_inner_height = outer_height.saturating_sub(2); + let terminal_cols = if pane_inner_width <= 4 { + pane_inner_width + } else { + pane_inner_width.saturating_sub(1) + }; + let inner = Rect::new( + outer_x.saturating_add(1), + outer_y.saturating_add(1), + terminal_cols, + pane_inner_height, + ); + Some(PopupResolvedGeometry { + outer: Rect::new(outer_x, outer_y, outer_width, outer_height), + inner, + }) +} + +impl Serialize for PopupSize { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Cells(cells) => serializer.serialize_u16(*cells), + Self::Percent(percent) => serializer.serialize_str(&format!("{percent}%")), + } + } +} + +impl<'de> Deserialize<'de> for PopupSize { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct PopupSizeVisitor; + + impl serde::de::Visitor<'_> for PopupSizeVisitor { + type Value = PopupSize; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a cell count or percentage string like 80%") + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + let value = + u16::try_from(value).map_err(|_| E::custom("cell count must fit in u16"))?; + Ok(PopupSize::Cells(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + let value = u16::try_from(value) + .map_err(|_| E::custom("cell count must be between 0 and 65535"))?; + Ok(PopupSize::Cells(value)) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + PopupSize::parse_percent_string(value).map_err(E::custom) + } + } + + deserializer.deserialize_any(PopupSizeVisitor) + } +} + +impl schemars::JsonSchema for PopupSize { + fn schema_name() -> Cow<'static, str> { + "PopupSize".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Outer popup size in terminal cells, including the border." + }, + { + "type": "string", + "pattern": "^(100|[1-9][0-9]?)%$", + "description": "Outer popup size as a percentage of the terminal area, for example 80%." + } + ] + }) + } +} + +#[cfg(test)] +mod tests { + use super::PopupSize; + + #[test] + fn parses_cells_and_percent() { + assert_eq!(PopupSize::parse_cli("120"), Ok(PopupSize::Cells(120))); + assert_eq!(PopupSize::parse_cli("80%"), Ok(PopupSize::Percent(80))); + assert_eq!(PopupSize::Percent(80).resolve(100), 80); + } + + #[test] + fn rejects_invalid_percent() { + assert!(PopupSize::parse_cli("0%").is_err()); + assert!(PopupSize::parse_cli("101%").is_err()); + assert!(PopupSize::parse_cli("%").is_err()); + } + + #[test] + fn string_deserialization_requires_percent() { + assert!(serde_json::from_value::(serde_json::json!("120")).is_err()); + assert_eq!( + serde_json::from_value::(serde_json::json!("80%")).unwrap(), + PopupSize::Percent(80) + ); + } + + #[test] + fn serializes_percent_as_string() { + assert_eq!( + serde_json::to_value(PopupSize::Percent(80)).unwrap(), + serde_json::json!("80%") + ); + assert_eq!( + serde_json::to_value(PopupSize::Cells(120)).unwrap(), + serde_json::json!(120) + ); + } + + #[test] + fn resolves_requested_outer_size_and_inner_terminal_area() { + let resolved = super::resolve_popup_geometry( + Some(PopupSize::Percent(80)), + Some(PopupSize::Percent(40)), + ratatui::layout::Rect::new(0, 0, 100, 30), + ) + .unwrap(); + assert_eq!(resolved.outer, ratatui::layout::Rect::new(10, 9, 80, 12)); + assert_eq!(resolved.inner, ratatui::layout::Rect::new(11, 10, 77, 10)); + } + + #[test] + fn allows_full_terminal_outer_size() { + let resolved = super::resolve_popup_geometry( + Some(PopupSize::Percent(100)), + Some(PopupSize::Percent(100)), + ratatui::layout::Rect::new(4, 2, 100, 30), + ) + .unwrap(); + + assert_eq!(resolved.outer, ratatui::layout::Rect::new(4, 2, 100, 30)); + assert_eq!(resolved.inner, ratatui::layout::Rect::new(5, 3, 97, 28)); + } + + #[test] + fn enforces_runtime_minimum_terminal_width() { + let resolved = super::resolve_popup_geometry( + Some(PopupSize::Cells(4)), + None, + ratatui::layout::Rect::new(0, 0, 80, 24), + ) + .unwrap(); + assert_eq!(resolved.outer.width, 6); + assert_eq!(resolved.inner.width, 4); + + assert!( + super::resolve_popup_geometry(None, None, ratatui::layout::Rect::new(0, 0, 5, 24),) + .is_none() + ); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 720b43f2..9c21aa1b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3462,6 +3462,7 @@ impl HeadlessServer { fn retained_pty_update_allowed_by_app_state(&self) -> bool { self.app.state.mode == app::Mode::Terminal + && self.app.state.popup_pane.is_none() && self.app.state.selection.is_none() && self.app.state.copy_mode.is_none() && self.app.state.context_menu.is_none() @@ -7800,6 +7801,136 @@ next_tab = "" assert_eq!((patched.width, patched.height), (80, 24)); } + #[tokio::test] + async fn retained_pty_update_declines_while_popup_is_visible() { + let (mut server, client_rx, _) = retained_test_server(b"tiled"); + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup-aaaa"); + let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + + server.render_and_stream(); + let initial = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial popup frame"), + ); + assert!(frame_text(&initial).contains("popup-aaaa")); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\rZ"); + + assert!(!server.render_retained_pty_update_and_stream()); + server.render_and_stream(); + let updated = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full popup fallback frame"), + ); + assert!(frame_text(&updated).contains("Zopup-aaaa")); + } + + #[tokio::test] + async fn popup_forces_host_mouse_capture_for_headless_client() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.app.state.mouse_capture = false; + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup"); + server.app.install_test_popup_runtime(popup_runtime); + + server.stream_host_mouse_capture_mode(); + + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("mouse capture message") + ), + ServerMessage::MouseCapture { enabled: true } + )); + } + + #[tokio::test] + async fn virtual_render_uses_popup_cursor() { + let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H"); + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[4;5H"); + let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + + let (_, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( + &mut server.app.state, + &server.app.terminal_runtimes, + ratatui::layout::Rect::new(0, 0, 80, 24), + true, + crate::kitty_graphics::HostCellSize::default(), + ); + let (_, inner) = + crate::ui::popup_pane_rects(&server.app.state, server.app.state.view.terminal_area) + .unwrap(); + let expected = server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .cursor_state(inner, true) + .unwrap(); + + assert_eq!( + cursor, + Some(crate::protocol::CursorState { + x: expected.x, + y: expected.y, + visible: expected.visible, + shape: expected.shape, + }) + ); + } + + #[tokio::test] + async fn virtual_render_does_not_resize_directly_attached_popup() { + let (mut server, _client_rx, _) = retained_test_server(b"tiled"); + let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(50, 13, b""); + let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + server + .app + .state + .direct_attach_resize_locks + .insert(terminal_id.clone()); + + let _ = crate::server::render_stream::render_virtual_with_runtime_registry( + &mut server.app.state, + &server.app.terminal_runtimes, + ratatui::layout::Rect::new(0, 0, 80, 24), + true, + crate::kitty_graphics::HostCellSize::default(), + ); + + assert_eq!( + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .current_size(), + (13, 50) + ); + } + #[tokio::test] async fn retained_pty_update_declines_while_toast_is_visible() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index d21dea56..facebd2c 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -290,15 +290,17 @@ pub(crate) fn render_virtual_with_runtime_registry( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> (ratatui::buffer::Buffer, Option) { + let popup_visible = app_state.popup_pane.is_some(); let pre_compute_suppresses_focused_terminal_cursor = - focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); + !popup_visible && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); if resize_panes { crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); } else { crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); } let suppress_focused_terminal_cursor = pre_compute_suppresses_focused_terminal_cursor - || focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); + || (!popup_visible + && focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes)); let backend = CursorTrackingBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); @@ -310,7 +312,9 @@ pub(crate) fn render_virtual_with_runtime_registry( .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); - let cursor = if suppress_focused_terminal_cursor { + let cursor = if popup_visible { + popup_terminal_cursor(app_state, terminal_runtimes) + } else if suppress_focused_terminal_cursor { None } else { focused_terminal_cursor(app_state, terminal_runtimes).or_else(|| { @@ -323,6 +327,25 @@ pub(crate) fn render_virtual_with_runtime_registry( (buffer, cursor) } +fn popup_terminal_cursor( + app_state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Option { + let popup = app_state.popup_pane.as_ref()?; + let runtime = terminal_runtimes.get(&popup.terminal_id)?; + if runtime.synchronized_output_active() { + return None; + } + let (_, inner) = crate::ui::popup_pane_rects(app_state, app_state.view.terminal_area)?; + let cursor = runtime.cursor_state(inner, true)?; + Some(CursorState { + x: cursor.x, + y: cursor.y, + visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), + shape: cursor.shape, + }) +} + /// Renders one server-owned terminal directly for `terminal attach` clients. pub(crate) fn render_terminal_virtual( runtime: &crate::terminal::TerminalRuntime, diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 29d19140..1d19ad4a 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -148,6 +148,7 @@ impl TerminalRuntime { cwd: std::path::PathBuf, command: &str, launch_env: &crate::pane::PaneLaunchEnv, + agent_detection: crate::pane::AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, @@ -161,6 +162,7 @@ impl TerminalRuntime { cwd, command, launch_env, + agent_detection, scrollback_limit_bytes, host_terminal_theme, events, @@ -170,6 +172,8 @@ impl TerminalRuntime { .map(Self) } + // Wrapper mirrors pane runtime construction arguments, including detection policy. + #[allow(clippy::too_many_arguments)] pub fn spawn_argv_command( pane_id: PaneId, rows: u16, @@ -177,6 +181,7 @@ impl TerminalRuntime { cwd: std::path::PathBuf, argv: &[String], launch_env: &crate::pane::PaneLaunchEnv, + agent_detection: crate::pane::AgentDetection, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, @@ -190,6 +195,7 @@ impl TerminalRuntime { cwd, argv, launch_env, + agent_detection, scrollback_limit_bytes, host_terminal_theme, events, @@ -218,6 +224,11 @@ impl TerminalRuntime { self.0.agent_detection_reset_notify_for_test() } + #[cfg(test)] + pub(crate) fn agent_detection_enabled_for_test(&self) -> bool { + self.0.agent_detection_enabled_for_test() + } + pub fn set_full_lifecycle_authority_active(&self, active: bool) { self.0.set_full_lifecycle_authority_active(active); } @@ -389,6 +400,10 @@ impl TerminalRuntime { self.0.send_paste(text).await } + pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError> { + self.0.try_send_paste(text) + } + pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { self.0.try_send_focus_event(event) } diff --git a/src/ui.rs b/src/ui.rs index 349a033a..85574989 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -38,7 +38,10 @@ use self::mobile::{ use self::navigator::render_navigator_overlay; pub(crate) use self::onboarding::onboarding_welcome_continue_rect; use self::onboarding::render_onboarding_overlay; -use self::panes::{compute_pane_infos, render_panes, resize_tab_panes}; +pub(crate) use self::panes::popup_pane_rects; +use self::panes::{ + compute_pane_infos, render_panes, render_popup_pane, resize_popup_pane, resize_tab_panes, +}; pub(crate) use self::release_notes::{ product_announcement_display_lines, release_notes_close_button_rect, release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE, @@ -287,6 +290,7 @@ fn compute_view_internal( ); if resize_panes { resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size); + resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); } let toast_hit_area = app @@ -364,6 +368,7 @@ fn compute_mobile_view( ); if resize_panes { resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size); + resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size); } let header_hits = compute_mobile_header_hit_areas(app, header_rect); @@ -424,6 +429,7 @@ pub fn render_with_runtime_registry( // Ambient notifications sit above panes, but below interactive overlays. render_notifications(app, frame, terminal_area); + render_popup_pane(app, terminal_runtimes, frame, terminal_area); match app.mode { Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), @@ -1392,6 +1398,8 @@ mod tests { command: "lazygit".to_string(), action: crate::config::CustomCommandAction::Pane, description: Some("open lazygit".to_string()), + width: None, + height: None, }, crate::config::CustomCommandKeybind { bindings: crate::config::ActionKeybinds::prefix("alt+h"), @@ -1399,6 +1407,8 @@ mod tests { command: "echo hello".to_string(), action: crate::config::CustomCommandAction::Shell, description: None, + width: None, + height: None, }, ]; diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 3241de12..ae4cc132 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph}, Frame, }; @@ -14,6 +14,7 @@ use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::{AppState, Mode}; use crate::layout::PaneInfo; +use crate::popup_size::resolve_popup_geometry; use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool { @@ -367,6 +368,67 @@ pub(super) fn render_panes( render_pane_borders(app, ws, frame); } +pub(crate) fn popup_pane_rects(app: &AppState, area: Rect) -> Option<(Rect, Rect)> { + let popup = app.popup_pane.as_ref()?; + resolve_popup_geometry(popup.width, popup.height, area) + .map(|geometry| (geometry.outer, geometry.inner)) +} + +pub(super) fn resize_popup_pane( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) { + let Some(popup) = app.popup_pane.as_ref() else { + return; + }; + let Some((_outer, inner)) = popup_pane_rects(app, area) else { + return; + }; + if app.direct_attach_resize_locks.contains(&popup.terminal_id) { + return; + } + if let Some(rt) = terminal_runtimes.get(&popup.terminal_id) { + rt.resize( + inner.height, + inner.width, + cell_size.width_px, + cell_size.height_px, + ); + } +} + +pub(super) fn render_popup_pane( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + frame: &mut Frame, + area: Rect, +) { + let Some(popup) = app.popup_pane.as_ref() else { + return; + }; + let Some((outer, inner)) = popup_pane_rects(app, area) else { + return; + }; + let Some(rt) = terminal_runtimes.get(&popup.terminal_id) else { + return; + }; + let title = app + .terminals + .get(&popup.terminal_id) + .and_then(|terminal| terminal.manual_label.as_deref()) + .unwrap_or("popup"); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.palette.accent)) + .title(pane_border_title(title, outer.width, true).unwrap_or_default()) + .style(Style::default().bg(app.palette.panel_bg)); + frame.render_widget(Clear, outer); + frame.render_widget(block, outer); + rt.render(frame, inner, !pane_is_scrolled_back(rt)); +} + #[derive(Clone, Copy, Default)] struct LineCell { up: bool, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index ab9829d0..15ca7e45 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -134,6 +134,7 @@ impl Tab { initial_cwd.clone(), argv, launch_env, + crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, events.clone(), @@ -359,6 +360,7 @@ impl Tab { actual_cwd.clone(), command, launch_env, + crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, self.events.clone(), @@ -372,6 +374,7 @@ impl Tab { actual_cwd.clone(), argv, launch_env, + crate::pane::AgentDetection::Enabled, scrollback_limit_bytes, host_terminal_theme, self.events.clone(),