aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 03:58:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 04:02:14 +0300
commit2c7c8beb079c5fd58a2e53efd2058df050ad9f55 (patch)
tree6756f671dc728d915b1d6ebececa8cf7f70b66fb /src/app
parent88370e1516182ab519d22a12fa82d5780a40fee6 (diff)
feat: add floating popup panes
refs #1125
Diffstat (limited to 'src/app')
-rw-r--r--src/app/api.rs16
-rw-r--r--src/app/api/plugins/manifest.rs15
-rw-r--r--src/app/api/plugins/mod.rs256
-rw-r--r--src/app/api/plugins/panes.rs33
-rw-r--r--src/app/input/mod.rs74
-rw-r--r--src/app/input/navigate.rs20
-rw-r--r--src/app/input/terminal.rs274
-rw-r--r--src/app/mod.rs187
-rw-r--r--src/app/popup.rs309
-rw-r--r--src/app/runtime.rs6
-rw-r--r--src/app/state.rs28
11 files changed, 1166 insertions, 52 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index d2c2858f..7bc8edf1 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -151,6 +151,15 @@ impl App {
}
if let AppEvent::PaneDied { pane_id } = &ev {
+ if self
+ .state
+ .popup_pane
+ .as_ref()
+ .is_some_and(|popup| popup.pane_id == *pane_id)
+ {
+ self.close_popup_pane();
+ return;
+ }
let previous_toast = self.state.toast.clone();
if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) {
self.sync_full_lifecycle_authority_detection_pauses();
@@ -1059,6 +1068,13 @@ impl App {
return self.handle_pane_send_input(request.id, params)
}
Method::PaneClose(target) => return self.handle_pane_close(request.id, target),
+ Method::PopupClose(_) => {
+ return if self.close_popup_pane() {
+ responses::encode_success(request.id, ResponseResult::Ok {})
+ } else {
+ responses::encode_error(request.id, "popup_not_open", "no popup is open")
+ };
+ }
Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params),
Method::IntegrationInstall(params) => {
return self.handle_integration_install(request.id, params);
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs
index 92ab4754..c2f17704 100644
--- a/src/app/api/plugins/manifest.rs
+++ b/src/app/api/plugins/manifest.rs
@@ -3,6 +3,7 @@ use crate::api::schema::{
PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform,
PluginSourceInfo, PluginSourceKind,
};
+use crate::popup_size::PopupSize;
const PLUGIN_ID_MAX_CHARS: usize = 120;
const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120;
@@ -68,6 +69,10 @@ struct RawPluginManifestPane {
platforms: Option<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,
})
}
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index b7f90df9..164d566e 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -349,8 +349,25 @@ impl App {
return encode_error(id, code, message);
}
let placement = params.placement.unwrap_or(pane.placement);
+ if placement != PluginPanePlacement::Popup
+ && (params.width.is_some() || params.height.is_some())
+ {
+ return encode_error(
+ id,
+ "invalid_params",
+ "width and height are only supported when placement is popup",
+ );
+ }
+ if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal
+ {
+ return encode_error(
+ id,
+ "ui_busy",
+ "popup panes can only open from the normal workspace view",
+ );
+ }
match placement {
- PluginPanePlacement::Overlay => {
+ PluginPanePlacement::Overlay | PluginPanePlacement::Popup => {
if params.workspace_id.is_some()
|| params.target_pane_id.is_some()
|| params.direction.is_some()
@@ -358,7 +375,7 @@ impl App {
return encode_error(
id,
"invalid_params",
- "overlay plugin panes target the active pane",
+ "overlay and popup plugin panes target the active pane",
);
}
}
@@ -386,6 +403,7 @@ impl App {
PluginPanePlacement::Overlay => {
self.open_plugin_overlay_pane(id, params, &plugin, pane)
}
+ PluginPanePlacement::Popup => self.open_plugin_popup_pane(id, params, &plugin, pane),
PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
self.open_plugin_split_pane(id, params, &plugin, pane, placement)
}
@@ -659,7 +677,7 @@ fn manifest_actions(
mod tests {
use super::*;
use crate::api::schema::{
- Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
+ Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -983,6 +1001,24 @@ platforms = ["linux", "macos", "windows"]
"#,
"plugin_requires_newer_herdr",
),
+ (
+ "plugin-non-popup-size",
+ r#"
+id = "example.non-popup-size"
+name = "Non Popup Size"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+
+[[panes]]
+id = "board"
+title = "Board"
+placement = "split"
+width = "80%"
+command = ["echo", "board"]
+"#,
+ "invalid_plugin_pane_size",
+ ),
];
for (name, manifest, expected_code) in cases {
@@ -1139,6 +1175,8 @@ command = ["echo", "b"]
plugin_id: "example.missing".into(),
entrypoint: "ui".into(),
placement: Some(PluginPanePlacement::Split),
+ width: None,
+ height: None,
workspace_id: None,
target_pane_id: None,
direction: None,
@@ -1151,6 +1189,98 @@ command = ["echo", "b"]
assert_eq!(value["error"]["code"], "plugin_not_found");
}
+ #[test]
+ fn plugin_pane_open_rejects_popup_size_for_non_popup_placement() {
+ let mut app = test_app();
+ let root = unique_temp_path("plugin-pane-non-popup-size-param");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let response = app.handle_api_request(Request {
+ id: "pane-open-size".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Split),
+ width: Some(crate::popup_size::PopupSize::Percent(80)),
+ height: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: Some(crate::api::schema::SplitDirection::Right),
+ cwd: None,
+ focus: false,
+ env: std::collections::HashMap::new(),
+ }),
+ });
+
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(value["error"]["code"], "invalid_params");
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn plugin_pane_open_popup_preserves_existing_ui_modes() {
+ let mut app = test_app();
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root_pane = app.state.workspaces[0].tabs[0].root_pane;
+ let root = unique_temp_path("plugin-popup-ui-busy");
+ write_manifest(&root);
+ link_manifest(&mut app, &root);
+
+ let open_popup = |app: &mut App, id: &str| {
+ app.handle_api_request(Request {
+ id: id.into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Popup),
+ width: None,
+ height: None,
+ workspace_id: None,
+ target_pane_id: None,
+ direction: None,
+ cwd: None,
+ focus: true,
+ env: std::collections::HashMap::new(),
+ }),
+ })
+ };
+
+ app.state.mode = crate::app::Mode::Settings;
+ app.state.settings.original_theme = Some("settings-theme".into());
+ let settings_response = open_popup(&mut app, "settings-popup");
+ let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap();
+ assert_eq!(settings_error["error"]["code"], "ui_busy");
+ assert_eq!(app.state.mode, crate::app::Mode::Settings);
+ assert_eq!(
+ app.state.settings.original_theme.as_deref(),
+ Some("settings-theme")
+ );
+ assert!(app.state.popup_pane.is_none());
+
+ let copy_mode = crate::app::state::CopyModeState {
+ pane_id: root_pane,
+ cursor_row: 2,
+ cursor_col: 3,
+ entry_offset_from_bottom: 4,
+ selection: None,
+ search: crate::app::state::CopyModeSearchState::default(),
+ };
+ app.state.mode = crate::app::Mode::Copy;
+ app.state.copy_mode = Some(copy_mode.clone());
+ let copy_response = open_popup(&mut app, "copy-popup");
+ let copy_error: serde_json::Value = serde_json::from_str(&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,
diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs
index b2988998..74a5df23 100644
--- a/src/app/api/plugins/panes.rs
+++ b/src/app/api/plugins/panes.rs
@@ -8,6 +8,39 @@ use crate::api::schema::{
use crate::app::App;
impl App {
+ pub(super) fn open_plugin_popup_pane(
+ &mut self,
+ id: String,
+ params: PluginPaneOpenParams,
+ plugin: &InstalledPluginInfo,
+ pane: PluginManifestPane,
+ ) -> String {
+ let context = self.current_plugin_context("plugin-pane");
+ let extra_env =
+ match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) {
+ Ok(env) => env,
+ Err((code, message)) => return encode_error(id, &code, message),
+ };
+ let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd));
+ let width = params.width.or(pane.width);
+ let height = params.height.or(pane.height);
+ if let Err(err) = self.spawn_popup_argv_command(
+ &pane.command,
+ cwd,
+ extra_env,
+ crate::app::popup::PopupGeometry { width, height },
+ ) {
+ return encode_error(id, "plugin_pane_open_failed", err.to_string());
+ }
+ let Some(popup) = self.state.popup_pane.as_ref() else {
+ return encode_error(id, "plugin_pane_open_failed", "plugin popup disappeared");
+ };
+ if let Some(terminal) = self.state.terminals.get_mut(&popup.terminal_id) {
+ terminal.set_manual_label(pane.title);
+ }
+ encode_success(id, ResponseResult::Ok {})
+ }
+
pub(super) fn open_plugin_overlay_pane(
&mut self,
id: String,
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 421b3738..21e9a49b 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -1,6 +1,8 @@
//! Input handling — translates crossterm key/mouse events into state mutations.
+use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
+use tracing::warn;
use crate::app::PaneClickState;
use crate::input::TerminalKey;
@@ -70,6 +72,10 @@ use super::App;
impl App {
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
+ if self.state.popup_pane.is_some() {
+ self.handle_terminal_key(key).await;
+ return;
+ }
let key_event = key.as_key_event();
if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) {
if let Some(text) = crate::platform::read_clipboard_text() {
@@ -111,6 +117,14 @@ impl App {
}
pub(super) async fn handle_paste(&mut self, text: String) {
+ if self.state.popup_pane.is_some() {
+ if let Some(runtime) = self.popup_runtime() {
+ let _ = runtime.send_paste(text).await;
+ } else {
+ self.close_popup_pane();
+ }
+ return;
+ }
if self.state.mode != Mode::Terminal {
self.paste_into_active_text_input(&text);
return;
@@ -239,6 +253,10 @@ impl App {
}
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
+ if self.state.popup_pane.is_some() {
+ self.handle_popup_mouse(mouse);
+ return;
+ }
if self.handle_overlay_mouse(mouse) {
return;
}
@@ -373,6 +391,62 @@ impl App {
}
}
+ fn handle_popup_mouse(&mut self, mouse: MouseEvent) {
+ let Some((_outer, inner)) =
+ crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area)
+ else {
+ return;
+ };
+ if mouse.column < inner.x
+ || mouse.column >= inner.x.saturating_add(inner.width)
+ || mouse.row < inner.y
+ || mouse.row >= inner.y.saturating_add(inner.height)
+ {
+ return;
+ }
+ let Some(rt) = self.popup_runtime() else {
+ self.close_popup_pane();
+ return;
+ };
+ let column = mouse.column.saturating_sub(inner.x);
+ let row = mouse.row.saturating_sub(inner.y);
+ let bytes = match mouse.kind {
+ MouseEventKind::ScrollUp
+ | MouseEventKind::ScrollDown
+ | MouseEventKind::ScrollLeft
+ | MouseEventKind::ScrollRight => match rt.wheel_routing() {
+ Some(crate::pane::WheelRouting::MouseReport) => {
+ rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
+ }
+ Some(crate::pane::WheelRouting::AlternateScroll) => {
+ rt.encode_alternate_scroll(mouse.kind)
+ }
+ Some(crate::pane::WheelRouting::HostScroll) | None => {
+ let lines_per_notch = self.state.mouse_scroll_lines;
+ match mouse.kind {
+ MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch),
+ MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch),
+ _ => {}
+ }
+ return;
+ }
+ },
+ MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
+ rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers)
+ }
+ MouseEventKind::Moved => {
+ rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers)
+ }
+ };
+ let Some(bytes) = bytes else {
+ return;
+ };
+ rt.scroll_reset();
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event");
+ }
+ }
+
fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool {
if self.state.mode != Mode::Terminal
|| !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 7f98f530..33177624 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -778,6 +778,7 @@ impl App {
crate::config::CustomCommandAction::Pane => {
self.spawn_pane_command(&binding.command, Vec::new())
}
+ crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding),
crate::config::CustomCommandAction::PluginAction => self
.invoke_plugin_action_from_keybind(binding.command.clone())
.map_err(std::io::Error::other),
@@ -798,6 +799,21 @@ impl App {
}
}
+ fn spawn_custom_popup_command(
+ &mut self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> io::Result<()> {
+ self.spawn_popup_shell_command(
+ &binding.command,
+ None,
+ self.custom_command_env().0,
+ crate::app::popup::PopupGeometry {
+ width: binding.width,
+ height: binding.height,
+ },
+ )
+ }
+
fn custom_command_env(&self) -> (Vec<(String, String)>, Option<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(
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index dd1e811f..e8e6495e 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -13,12 +13,31 @@ struct PreparedPaneInput {
bytes: Bytes,
}
+enum PreparedPopupInput {
+ NotOpen,
+ Consumed,
+ Bytes(Bytes),
+}
+
fn is_modifier_only_key(code: &KeyCode) -> bool {
matches!(code, KeyCode::Modifier(_))
}
impl App {
pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
+ match self.prepare_popup_key_forward(key) {
+ PreparedPopupInput::NotOpen => {}
+ PreparedPopupInput::Consumed => return,
+ PreparedPopupInput::Bytes(bytes) => {
+ let Some(runtime) = self.popup_runtime() else {
+ self.close_popup_pane();
+ return;
+ };
+ let _ = runtime.try_send_bytes(bytes);
+ return;
+ }
+ }
+
let Some(input) = self.prepare_terminal_key_forward(key) else {
return;
};
@@ -192,7 +211,38 @@ impl App {
})
}
+ fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput {
+ if self.state.popup_pane.is_none() {
+ return PreparedPopupInput::NotOpen;
+ }
+ let Some(rt) = self.popup_runtime() else {
+ self.close_popup_pane();
+ return PreparedPopupInput::Consumed;
+ };
+ rt.scroll_reset();
+ let bytes = rt.encode_terminal_key(key);
+ self.state.mode = Mode::Terminal;
+ if bytes.is_empty() {
+ PreparedPopupInput::Consumed
+ } else {
+ PreparedPopupInput::Bytes(Bytes::from(bytes))
+ }
+ }
+
pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
+ match self.prepare_popup_key_forward(key) {
+ PreparedPopupInput::NotOpen => {}
+ PreparedPopupInput::Consumed => return,
+ PreparedPopupInput::Bytes(bytes) => {
+ let Some(runtime) = self.popup_runtime() else {
+ self.close_popup_pane();
+ return;
+ };
+ let _ = runtime.send_bytes(bytes).await;
+ return;
+ }
+ }
+
let Some(input) = self.prepare_terminal_key_forward(key) else {
return;
};
@@ -213,6 +263,45 @@ mod tests {
use super::*;
use crate::{config::Config, events::AppEvent, workspace::Workspace};
+ #[cfg(unix)]
+ fn app_with_spawned_workspace() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.default_shell = "/bin/sh".into();
+ let (workspace, terminal, runtime) = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.terminal_runtimes.insert(terminal.id.clone(), runtime);
+ app.state.terminals.insert(terminal.id.clone(), terminal);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app
+ }
+
+ #[cfg(unix)]
+ fn shutdown_test_runtimes(app: &mut App) {
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
@@ -1093,6 +1182,8 @@ mod tests {
command,
action: crate::config::CustomCommandAction::Shell,
description: None,
+ width: None,
+ height: None,
}];
app.handle_terminal_key(TerminalKey::new(
@@ -1109,33 +1200,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn direct_custom_pane_command_opens_overlay_pane() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.default_shell = "/usr/bin/true".into();
- let (workspace, terminal, runtime) = Workspace::new(
- std::env::current_dir().unwrap_or_else(|_| "/".into()),
- 24,
- 80,
- app.state.pane_scrollback_limit_bytes,
- app.state.host_terminal_theme,
- crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
- app.event_tx.clone(),
- app.render_notify.clone(),
- app.render_dirty.clone(),
- )
- .expect("workspace should spawn");
- app.state.workspaces = vec![workspace];
- app.terminal_runtimes.insert(terminal.id.clone(), runtime);
- app.state.terminals.insert(terminal.id.clone(), terminal);
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
+ let mut app = app_with_spawned_workspace();
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
@@ -1143,6 +1208,8 @@ mod tests {
command: "printf direct-pane".into(),
action: crate::config::CustomCommandAction::Pane,
description: None,
+ width: None,
+ height: None,
}];
app.handle_terminal_key(TerminalKey::new(
@@ -1155,10 +1222,157 @@ mod tests {
assert!(app.state.workspaces[0].tabs[0].zoomed);
assert_eq!(app.state.mode, Mode::Terminal);
- let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
- for (_terminal_id, runtime) in runtimes {
- runtime.shutdown();
+ shutdown_test_runtimes(&mut app);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn direct_custom_popup_command_opens_layout_neutral_popup() {
+ let mut app = app_with_spawned_workspace();
+
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command: "sleep 1".into(),
+ action: crate::config::CustomCommandAction::Popup,
+ description: None,
+ width: Some(crate::popup_size::PopupSize::Cells(60)),
+ height: Some(crate::popup_size::PopupSize::Cells(12)),
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert!(app.state.popup_pane.is_some());
+ assert!(!app
+ .popup_runtime()
+ .unwrap()
+ .agent_detection_enabled_for_test());
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+ assert!(!app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let snapshot = crate::persist::capture(
+ &app.state.workspaces,
+ &app.state.terminals,
+ &app.terminal_runtimes,
+ app.state.active,
+ app.state.selected,
+ app.state.sidebar_width,
+ app.state.sidebar_section_split,
+ app.state.collapsed_space_keys.clone(),
+ );
+ assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1);
+ assert!(matches!(
+ snapshot.workspaces[0].tabs[0].layout,
+ crate::persist::LayoutSnapshot::Pane(_)
+ ));
+
+ shutdown_test_runtimes(&mut app);
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn direct_custom_popup_command_closes_after_exit() {
+ let mut app = app_with_spawned_workspace();
+ let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap();
+ let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap();
+
+ let output_path = unique_temp_path("custom-popup-command");
+ let command = format!(
+ "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
+ output_path.display()
+ );
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Popup,
+ description: None,
+ width: None,
+ height: None,
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert_eq!(
+ wait_for_file(&output_path),
+ format!("unset|{focused_pane_id}")
+ );
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ app.drain_internal_events();
+ if app.state.popup_pane.is_none() {
+ break;
+ }
}
+
+ assert!(app.state.popup_pane.is_none());
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+
+ shutdown_test_runtimes(&mut app);
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn popup_forwards_escape_instead_of_closing() {
+ let mut app = app_for_mouse_test();
+ let (runtime, mut rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 40,
+ 2,
+ 1024,
+ b"one\r\ntwo\r\nthree\r\n",
+ 4,
+ );
+ runtime.scroll_up(1);
+ assert!(runtime
+ .scroll_metrics()
+ .is_some_and(|metrics| metrics.offset_from_bottom > 0));
+ app.install_test_popup_runtime(runtime);
+ app.state.mode = Mode::Settings;
+
+ app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()));
+
+ assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b");
+ assert!(app.state.popup_pane.is_some());
+ assert_eq!(
+ app.popup_runtime()
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
+ .map(|metrics| metrics.offset_from_bottom),
+ Some(0)
+ );
+ }
+
+ #[tokio::test]
+ async fn local_popup_input_waits_for_channel_capacity() {
+ let mut app = app_for_mouse_test();
+ let (runtime, mut rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1);
+ runtime
+ .try_send_bytes(Bytes::from_static(b"queued"))
+ .unwrap();
+ app.install_test_popup_runtime(runtime);
+ app.state.mode = Mode::Settings;
+
+ let mut send = Box::pin(
+ app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())),
+ );
+ assert!(
+ tokio::time::timeout(std::time::Duration::from_millis(20), &mut send)
+ .await
+ .is_err()
+ );
+
+ assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued");
+ send.await;
+ assert_eq!(rx.recv().await.unwrap().as_ref(), b"x");
}
#[tokio::test]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 7391d7d3..a77f2536 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -13,6 +13,7 @@ mod config_io;
mod creation;
mod ids;
mod input;
+mod popup;
mod runtime;
mod runtime_mutations;
mod session;
@@ -659,6 +660,7 @@ impl App {
pane_graphics_layers: std::collections::HashMap::new(),
pane_graphics_streams: std::collections::HashMap::new(),
pane_graphics_revision: 0,
+ popup_pane: None,
plugin_command_logs: Vec::new(),
next_plugin_command_log_id: 1,
plugin_commands_in_flight: 0,
@@ -1577,7 +1579,8 @@ impl App {
let key_id = repeat_key_identity(&key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
- if self.state.mode == Mode::Terminal {
+ if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal
+ {
self.suppressed_repeat_keys.remove(&key_id);
self.handle_terminal_key_headless(key);
} else {
@@ -1586,7 +1589,8 @@ impl App {
}
}
crossterm::event::KeyEventKind::Repeat => {
- if self.state.mode == Mode::Terminal
+ if (self.state.popup_pane.is_some()
+ || self.state.mode == Mode::Terminal)
&& !self.suppressed_repeat_keys.contains(&key_id)
{
self.handle_terminal_key_headless(key);
@@ -1600,7 +1604,7 @@ impl App {
}
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
- if self.state.mouse_capture {
+ if self.state.popup_pane.is_some() || self.state.mouse_capture {
self.handle_mouse_event_headless(mouse);
} else {
self.state
@@ -1608,7 +1612,8 @@ impl App {
}
}
crate::raw_input::RawInputEvent::Paste(text) => {
- if self.state.mode != Mode::Terminal {
+ if self.try_route_paste_to_popup(&text) {
+ } else if self.state.mode != Mode::Terminal {
self.paste_into_active_text_input(&text);
} else {
if let Some(ws_idx) = self.state.active {
@@ -1619,17 +1624,7 @@ impl App {
ws_idx,
focused,
) {
- let _ = runtime.try_send_bytes(bytes::Bytes::from(
- if runtime
- .input_state()
- .map(|s| s.bracketed_paste)
- .unwrap_or(false)
- {
- format!("\x1b[200~{text}\x1b[201~")
- } else {
- text
- },
- ));
+ let _ = runtime.try_send_paste(text);
}
}
}
@@ -4918,6 +4913,168 @@ last_pane = "prefix+tab"
);
}
+ #[tokio::test]
+ async fn route_client_events_pastes_only_into_popup() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("tiled");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
+ workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12);
+ app.install_test_popup_runtime(popup_runtime);
+ assert!(app
+ .state
+ .should_capture_host_mouse_from(&app.terminal_runtimes));
+
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())],
+ true,
+ );
+
+ assert_eq!(
+ popup_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"popup-only")
+ );
+ assert!(tiled_rx.try_recv().is_err());
+
+ app.route_client_events(
+ vec![raw_key(
+ KeyCode::Char('x'),
+ KeyModifiers::NONE,
+ KeyEventKind::Press,
+ )],
+ true,
+ );
+ assert_eq!(
+ popup_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"x")
+ );
+ assert!(tiled_rx.try_recv().is_err());
+
+ app.state.mode = Mode::Settings;
+ assert!(
+ app.handle_raw_input_event(raw_key(
+ KeyCode::Char('y'),
+ KeyModifiers::NONE,
+ KeyEventKind::Repeat,
+ ))
+ .await
+ );
+ assert_eq!(
+ popup_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"y")
+ );
+ assert!(tiled_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn route_client_events_discards_paste_when_popup_runtime_is_missing() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("tiled");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
+ workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ let install_missing_popup = |app: &mut App| {
+ let popup_terminal_id = crate::terminal::TerminalId::alloc();
+ app.state.terminals.insert(
+ popup_terminal_id.clone(),
+ crate::terminal::TerminalState::new(
+ popup_terminal_id.clone(),
+ std::path::PathBuf::from("/popup"),
+ ),
+ );
+ app.state.popup_pane = Some(state::PopupPaneState {
+ pane_id: crate::layout::PaneId::alloc(),
+ terminal_id: popup_terminal_id,
+ width: None,
+ height: None,
+ });
+ };
+ install_missing_popup(&mut app);
+
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())],
+ true,
+ );
+
+ assert!(tiled_rx.try_recv().is_err());
+ assert!(app.state.popup_pane.is_none());
+
+ install_missing_popup(&mut app);
+ assert!(
+ app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste(
+ "discard-monolithic".into(),
+ ))
+ .await
+ );
+ assert!(tiled_rx.try_recv().is_err());
+ assert!(app.state.popup_pane.is_none());
+ }
+
+ #[tokio::test]
+ async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("tiled");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
+ tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h");
+ workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.mouse_capture = false;
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24);
+
+ let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12);
+ popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h");
+ app.install_test_popup_runtime(popup_runtime);
+ let (_, inner) =
+ crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap();
+
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Mouse(
+ crossterm::event::MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(
+ crossterm::event::MouseButton::Left,
+ ),
+ column: inner.x,
+ row: inner.y,
+ modifiers: crossterm::event::KeyModifiers::NONE,
+ },
+ )],
+ true,
+ );
+
+ assert!(popup_rx.try_recv().is_ok());
+ assert!(tiled_rx.try_recv().is_err());
+
+ assert!(
+ app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse(
+ crossterm::event::MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(
+ crossterm::event::MouseButton::Left,
+ ),
+ column: inner.x + 1,
+ row: inner.y,
+ modifiers: crossterm::event::KeyModifiers::NONE,
+ },
+ ))
+ .await
+ );
+ assert!(popup_rx.try_recv().is_ok());
+ assert!(tiled_rx.try_recv().is_err());
+ }
+
#[test]
fn route_client_input_closes_release_notes_modal() {
let mut app = test_app();
diff --git a/src/app/popup.rs b/src/app/popup.rs
new file mode 100644
index 00000000..3aeef35c
--- /dev/null
+++ b/src/app/popup.rs
@@ -0,0 +1,309 @@
+use std::path::PathBuf;
+
+use crate::app::{App, Mode};
+use crate::layout::PaneId;
+use crate::pane::PaneLaunchEnv;
+use crate::popup_size::{resolve_popup_geometry, PopupSize};
+use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) struct PopupGeometry {
+ pub width: Option<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");
+ }
+}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 6d63f139..cad49372 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -142,7 +142,7 @@ impl App {
let key_id = repeat_key_identity(&key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
- if self.state.mode == Mode::Terminal {
+ if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal {
self.suppressed_repeat_keys.remove(&key_id);
} else {
self.suppressed_repeat_keys.insert(key_id);
@@ -151,7 +151,7 @@ impl App {
true
}
crossterm::event::KeyEventKind::Repeat => {
- if self.state.mode == Mode::Terminal
+ if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal)
&& !self.suppressed_repeat_keys.contains(&key_id)
{
self.handle_key(key).await;
@@ -171,7 +171,7 @@ impl App {
true
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
- if self.state.mouse_capture {
+ if self.state.popup_pane.is_some() || self.state.mouse_capture {
self.handle_mouse(mouse);
} else {
self.state
diff --git a/src/app/state.rs b/src/app/state.rs
index c0f2c8bb..d821407b 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -53,6 +53,14 @@ fn pane_graphics_data_fingerprint(data: &[u8]) -> u64 {
hasher.finish()
}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct PopupPaneState {
+ pub pane_id: PaneId,
+ pub terminal_id: crate::terminal::TerminalId,
+ pub width: Option<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");
}