feat: add floating popup panes

refs #1125
This commit is contained in:
Ogulcan Celik 2026-07-15 03:58:07 +03:00
parent 88370e1516
commit 2c7c8beb07
33 changed files with 2049 additions and 104 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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 <pane_id>
herdr plugin pane close <pane_id>
```
@ -413,7 +413,12 @@ herdr plugin pane close <pane_id>
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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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(_)

View File

@ -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")]

View File

@ -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<Vec<PluginPlatform>>,
#[serde(default)]
pub placement: PluginPanePlacement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<PopupSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<PopupSize>,
pub command: Vec<String>,
}
@ -407,6 +412,10 @@ pub struct PluginPaneOpenParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<PluginPanePlacement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<PopupSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<PopupSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String>,
@ -427,6 +436,7 @@ pub struct PluginPaneOpenParams {
pub enum PluginPanePlacement {
#[default]
Overlay,
Popup,
Split,
Tab,
Zoomed,

View File

@ -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!({}));
}

View File

@ -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",

View File

@ -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);

View File

@ -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<Vec<RawPlatform>>,
#[serde(default)]
placement: PluginPanePlacement,
#[serde(default)]
width: Option<PopupSize>,
#[serde(default)]
height: Option<PopupSize>,
command: Vec<String>,
}
@ -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,
})
}

View File

@ -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(&copy_response).unwrap();
assert_eq!(copy_error["error"]["code"], "ui_busy");
assert_eq!(app.state.mode, crate::app::Mode::Copy);
assert_eq!(app.state.copy_mode, Some(copy_mode));
assert!(app.state.popup_pane.is_none());
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[tokio::test]
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
@ -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,

View File

@ -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,

View File

@ -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))

View File

@ -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<std::path::PathBuf>) {
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(

View File

@ -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]

View File

@ -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();

309
src/app/popup.rs Normal file
View File

@ -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<PopupSize>,
pub height: Option<PopupSize>,
}
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<PathBuf>,
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<PathBuf>,
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<F>(
&mut self,
cwd: Option<PathBuf>,
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<Vec<String>>)>,
{
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");
}
}

View File

@ -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

View File

@ -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<crate::popup_size::PopupSize>,
pub height: Option<crate::popup_size::PopupSize>,
}
// ---------------------------------------------------------------------------
// Selection autoscroll types
// ---------------------------------------------------------------------------
@ -1515,6 +1523,8 @@ pub struct AppState {
pub(crate) pane_graphics_streams: std::collections::HashMap<PaneId, String>,
/// 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<PopupPaneState>,
/// Recent plugin action/event command executions.
pub(crate) plugin_command_logs: Vec<crate::api::schema::PluginCommandLogInfo>,
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");
}

View File

@ -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<i32> {
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<i32> {
};
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<i32> {
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<i32> {
}))
}
fn parse_popup_dimension(value: &str, flag: &str) -> Option<PopupSize> {
match PopupSize::parse_cli(value) {
Ok(value) => Some(value),
Err(message) => {
eprintln!("{flag} {message}");
None
}
}
}
fn plugin_pane_focus(args: &[String]) -> std::io::Result<i32> {
let Some(pane_id) = args.first() else {
eprintln!("usage: herdr plugin pane focus <pane_id>");
@ -635,6 +668,7 @@ fn required_value(args: &[String], index: &mut usize, flag: &str) -> Option<Stri
fn parse_pane_placement(value: &str) -> Option<PluginPanePlacement> {
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 <pane_id>");
eprintln!(" herdr plugin pane close <pane_id>");
}

View File

@ -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<String>,
/// Optional popup width as cells or a percentage string when type = "popup".
pub width: Option<PopupSize>,
/// Optional popup height as cells or a percentage string when type = "popup".
pub height: Option<PopupSize>,
}
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<String>,
pub width: Option<PopupSize>,
pub height: Option<PopupSize>,
}
/// 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")));
}
}

View File

@ -79,6 +79,7 @@ mod persist;
mod platform;
mod plugin_command;
mod plugin_paths;
mod popup_size;
mod product_announcements;
mod protocol;
mod pty;

View File

@ -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<PaneLaunchIdentity>,
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<Option<PendingAgentRelease>>,
now: std::time::Instant,
@ -917,7 +940,7 @@ pub struct PaneRuntime {
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
// Task handles for deterministic shutdown
detect_handle: tokio::task::AbortHandle,
detect_handle: Option<tokio::task::AbortHandle>,
}
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<AppEvent>,
@ -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<AppEvent>,
@ -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<Self> {
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<Bytes>> {
self.send_bytes(self.paste_payload(text)).await
}
pub fn try_send_paste(&self, text: String) -> Result<(), mpsc::error::TrySendError<Bytes>> {
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));

252
src/popup_size.rs Normal file
View File

@ -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<Self, String> {
if let Some(percent) = value.strip_suffix('%') {
let percent = percent
.parse::<u8>()
.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::<u16>()
.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<Self, String> {
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<PopupSize>,
height: Option<PopupSize>,
area: Rect,
) -> Option<PopupResolvedGeometry> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: u64) -> Result<Self::Value, E>
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<E>(self, value: i64) -> Result<Self::Value, E>
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<E>(self, value: &str) -> Result<Self::Value, E>
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::<PopupSize>(serde_json::json!("120")).is_err());
assert_eq!(
serde_json::from_value::<PopupSize>(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()
);
}
}

View File

@ -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");

View File

@ -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<CursorState>) {
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<CursorState> {
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,

View File

@ -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<AppEvent>,
@ -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<AppEvent>,
@ -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<Bytes>> {
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)
}

View File

@ -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,
},
];

View File

@ -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,

View File

@ -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(),