parent
88370e1516
commit
2c7c8beb07
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,8 +349,25 @@ impl App {
|
|||
return encode_error(id, code, message);
|
||||
}
|
||||
let placement = params.placement.unwrap_or(pane.placement);
|
||||
if placement != PluginPanePlacement::Popup
|
||||
&& (params.width.is_some() || params.height.is_some())
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"invalid_params",
|
||||
"width and height are only supported when placement is popup",
|
||||
);
|
||||
}
|
||||
if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal
|
||||
{
|
||||
return encode_error(
|
||||
id,
|
||||
"ui_busy",
|
||||
"popup panes can only open from the normal workspace view",
|
||||
);
|
||||
}
|
||||
match placement {
|
||||
PluginPanePlacement::Overlay => {
|
||||
PluginPanePlacement::Overlay | PluginPanePlacement::Popup => {
|
||||
if params.workspace_id.is_some()
|
||||
|| params.target_pane_id.is_some()
|
||||
|| params.direction.is_some()
|
||||
|
|
@ -358,7 +375,7 @@ impl App {
|
|||
return encode_error(
|
||||
id,
|
||||
"invalid_params",
|
||||
"overlay plugin panes target the active pane",
|
||||
"overlay and popup plugin panes target the active pane",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -386,6 +403,7 @@ impl App {
|
|||
PluginPanePlacement::Overlay => {
|
||||
self.open_plugin_overlay_pane(id, params, &plugin, pane)
|
||||
}
|
||||
PluginPanePlacement::Popup => self.open_plugin_popup_pane(id, params, &plugin, pane),
|
||||
PluginPanePlacement::Split | PluginPanePlacement::Zoomed => {
|
||||
self.open_plugin_split_pane(id, params, &plugin, pane, placement)
|
||||
}
|
||||
|
|
@ -659,7 +677,7 @@ fn manifest_actions(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::schema::{
|
||||
Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
|
||||
Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
|
||||
};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
|
|
@ -983,6 +1001,24 @@ platforms = ["linux", "macos", "windows"]
|
|||
"#,
|
||||
"plugin_requires_newer_herdr",
|
||||
),
|
||||
(
|
||||
"plugin-non-popup-size",
|
||||
r#"
|
||||
id = "example.non-popup-size"
|
||||
name = "Non Popup Size"
|
||||
version = "0.1.0"
|
||||
min_herdr_version = "0.6.10"
|
||||
platforms = ["linux", "macos", "windows"]
|
||||
|
||||
[[panes]]
|
||||
id = "board"
|
||||
title = "Board"
|
||||
placement = "split"
|
||||
width = "80%"
|
||||
command = ["echo", "board"]
|
||||
"#,
|
||||
"invalid_plugin_pane_size",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, manifest, expected_code) in cases {
|
||||
|
|
@ -1139,6 +1175,8 @@ command = ["echo", "b"]
|
|||
plugin_id: "example.missing".into(),
|
||||
entrypoint: "ui".into(),
|
||||
placement: Some(PluginPanePlacement::Split),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
@ -1151,6 +1189,98 @@ command = ["echo", "b"]
|
|||
assert_eq!(value["error"]["code"], "plugin_not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_pane_open_rejects_popup_size_for_non_popup_placement() {
|
||||
let mut app = test_app();
|
||||
let root = unique_temp_path("plugin-pane-non-popup-size-param");
|
||||
write_manifest(&root);
|
||||
link_manifest(&mut app, &root);
|
||||
|
||||
let response = app.handle_api_request(Request {
|
||||
id: "pane-open-size".into(),
|
||||
method: Method::PluginPaneOpen(PluginPaneOpenParams {
|
||||
plugin_id: "example.worktree-bootstrap".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Split),
|
||||
width: Some(crate::popup_size::PopupSize::Percent(80)),
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: Some(crate::api::schema::SplitDirection::Right),
|
||||
cwd: None,
|
||||
focus: false,
|
||||
env: std::collections::HashMap::new(),
|
||||
}),
|
||||
});
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(value["error"]["code"], "invalid_params");
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_pane_open_popup_preserves_existing_ui_modes() {
|
||||
let mut app = test_app();
|
||||
app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
let root_pane = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let root = unique_temp_path("plugin-popup-ui-busy");
|
||||
write_manifest(&root);
|
||||
link_manifest(&mut app, &root);
|
||||
|
||||
let open_popup = |app: &mut App, id: &str| {
|
||||
app.handle_api_request(Request {
|
||||
id: id.into(),
|
||||
method: Method::PluginPaneOpen(PluginPaneOpenParams {
|
||||
plugin_id: "example.worktree-bootstrap".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Popup),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
env: std::collections::HashMap::new(),
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
app.state.mode = crate::app::Mode::Settings;
|
||||
app.state.settings.original_theme = Some("settings-theme".into());
|
||||
let settings_response = open_popup(&mut app, "settings-popup");
|
||||
let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap();
|
||||
assert_eq!(settings_error["error"]["code"], "ui_busy");
|
||||
assert_eq!(app.state.mode, crate::app::Mode::Settings);
|
||||
assert_eq!(
|
||||
app.state.settings.original_theme.as_deref(),
|
||||
Some("settings-theme")
|
||||
);
|
||||
assert!(app.state.popup_pane.is_none());
|
||||
|
||||
let copy_mode = crate::app::state::CopyModeState {
|
||||
pane_id: root_pane,
|
||||
cursor_row: 2,
|
||||
cursor_col: 3,
|
||||
entry_offset_from_bottom: 4,
|
||||
selection: None,
|
||||
search: crate::app::state::CopyModeSearchState::default(),
|
||||
};
|
||||
app.state.mode = crate::app::Mode::Copy;
|
||||
app.state.copy_mode = Some(copy_mode.clone());
|
||||
let copy_response = open_popup(&mut app, "copy-popup");
|
||||
let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap();
|
||||
assert_eq!(copy_error["error"]["code"], "ui_busy");
|
||||
assert_eq!(app.state.mode, crate::app::Mode::Copy);
|
||||
assert_eq!(app.state.copy_mode, Some(copy_mode));
|
||||
assert!(app.state.popup_pane.is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
|
||||
|
|
@ -1200,6 +1330,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \"$PWD\" \
|
|||
plugin_id: "example.pane".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Overlay),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
@ -1305,6 +1437,8 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL
|
|||
plugin_id: "example.path-env".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Overlay),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
@ -1406,6 +1540,8 @@ command = ["sh", "-c", "sleep 1"]
|
|||
plugin_id: "example.tab".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: None,
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
@ -1487,6 +1623,8 @@ command = ["sh", "-c", "sleep 1"]
|
|||
plugin_id: "example.split".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: Some(PluginPanePlacement::Zoomed),
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: Some(crate::api::schema::SplitDirection::Right),
|
||||
|
|
@ -1564,6 +1702,8 @@ command = ["sh", "-c", "sleep 1"]
|
|||
plugin_id: "example.overlay".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: None,
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
@ -1598,6 +1738,114 @@ command = ["sh", "-c", "sleep 1"]
|
|||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn plugin_pane_open_popup_is_layout_neutral() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
true,
|
||||
None,
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
);
|
||||
app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-popup")];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = crate::app::Mode::Terminal;
|
||||
let root_pane = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let root_public = app.public_pane_id(0, root_pane).unwrap();
|
||||
|
||||
let root = unique_temp_path("plugin-pane-popup");
|
||||
let env_capture = root.join("popup-env.txt");
|
||||
let manifest = format!(
|
||||
r#"
|
||||
id = "example.popup"
|
||||
name = "Popup Plugin"
|
||||
version = "0.1.0"
|
||||
min_herdr_version = "0.6.10"
|
||||
platforms = ["linux", "macos"]
|
||||
|
||||
[[panes]]
|
||||
id = "board"
|
||||
title = "Plugin Popup"
|
||||
placement = "popup"
|
||||
width = "80%"
|
||||
height = "40%"
|
||||
command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
"#,
|
||||
env_capture.display()
|
||||
);
|
||||
write_manifest_content(&root, &manifest);
|
||||
link_manifest(&mut app, &root);
|
||||
|
||||
let open = app.handle_api_request(Request {
|
||||
id: "pane-open-popup".into(),
|
||||
method: Method::PluginPaneOpen(PluginPaneOpenParams {
|
||||
plugin_id: "example.popup".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: None,
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
cwd: None,
|
||||
focus: true,
|
||||
env: std::collections::HashMap::new(),
|
||||
}),
|
||||
});
|
||||
assert_eq!(response_result(&open), ResponseResult::Ok {});
|
||||
assert_eq!(
|
||||
read_capture_when_ready(&env_capture, || {
|
||||
app.drain_internal_events();
|
||||
}),
|
||||
"unset"
|
||||
);
|
||||
|
||||
let opened_pane_id = app.state.popup_pane.as_ref().unwrap().pane_id;
|
||||
assert!(!app.state.plugin_panes.contains_key(&opened_pane_id));
|
||||
app.state.assert_invariants_for_test();
|
||||
app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
|
||||
let (outer, inner) = crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area)
|
||||
.expect("popup rects");
|
||||
assert_eq!((outer.width, outer.height), (80, 12));
|
||||
assert_eq!((inner.width, inner.height), (77, 10));
|
||||
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
|
||||
assert!(!app.state.workspaces[0].tabs[0].zoomed);
|
||||
|
||||
let pane_list = app.handle_api_request(Request {
|
||||
id: "pane-list-popup".into(),
|
||||
method: Method::PaneList(PaneListParams {
|
||||
workspace_id: Some(app.public_workspace_id(0)),
|
||||
}),
|
||||
});
|
||||
let ResponseResult::PaneList { panes } = response_result(&pane_list) else {
|
||||
panic!("expected pane list response: {pane_list}");
|
||||
};
|
||||
assert_eq!(panes.len(), 1);
|
||||
assert_eq!(panes[0].pane_id, root_public);
|
||||
assert!(panes[0].focused);
|
||||
assert_eq!(
|
||||
app.current_plugin_context("popup-open").focused_pane_id,
|
||||
Some(root_public)
|
||||
);
|
||||
assert!(event_hub.events_after(0).is_empty());
|
||||
|
||||
app.handle_internal_event(crate::events::AppEvent::PaneDied {
|
||||
pane_id: opened_pane_id,
|
||||
});
|
||||
assert!(app.state.popup_pane.is_none());
|
||||
assert!(event_hub.events_after(0).is_empty());
|
||||
|
||||
for (_, runtime) in app.terminal_runtimes.drain() {
|
||||
runtime.shutdown();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_action_list_and_invoke_with_context() {
|
||||
let mut app = test_app();
|
||||
|
|
@ -1729,6 +1977,8 @@ command = ["sh", "-c", "sleep 1"]
|
|||
plugin_id: "example.worktree-bootstrap".into(),
|
||||
entrypoint: "board".into(),
|
||||
placement: None,
|
||||
width: None,
|
||||
height: None,
|
||||
workspace_id: None,
|
||||
target_pane_id: None,
|
||||
direction: None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
187
src/app/mod.rs
187
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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ mod persist;
|
|||
mod platform;
|
||||
mod plugin_command;
|
||||
mod plugin_paths;
|
||||
mod popup_size;
|
||||
mod product_announcements;
|
||||
mod protocol;
|
||||
mod pty;
|
||||
|
|
|
|||
118
src/pane.rs
118
src/pane.rs
|
|
@ -65,21 +65,26 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) {
|
|||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct PaneLaunchEnv {
|
||||
extra: Vec<(String, String)>,
|
||||
identity: Option<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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
12
src/ui.rs
12
src/ui.rs
|
|
@ -38,7 +38,10 @@ use self::mobile::{
|
|||
use self::navigator::render_navigator_overlay;
|
||||
pub(crate) use self::onboarding::onboarding_welcome_continue_rect;
|
||||
use self::onboarding::render_onboarding_overlay;
|
||||
use self::panes::{compute_pane_infos, render_panes, resize_tab_panes};
|
||||
pub(crate) use self::panes::popup_pane_rects;
|
||||
use self::panes::{
|
||||
compute_pane_infos, render_panes, render_popup_pane, resize_popup_pane, resize_tab_panes,
|
||||
};
|
||||
pub(crate) use self::release_notes::{
|
||||
product_announcement_display_lines, release_notes_close_button_rect,
|
||||
release_notes_display_lines, release_notes_wrapped_line_count, PRODUCT_ANNOUNCEMENT_MODAL_SIZE,
|
||||
|
|
@ -287,6 +290,7 @@ fn compute_view_internal(
|
|||
);
|
||||
if resize_panes {
|
||||
resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size);
|
||||
resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size);
|
||||
}
|
||||
|
||||
let toast_hit_area = app
|
||||
|
|
@ -364,6 +368,7 @@ fn compute_mobile_view(
|
|||
);
|
||||
if resize_panes {
|
||||
resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size);
|
||||
resize_popup_pane(app, terminal_runtimes, terminal_area, cell_size);
|
||||
}
|
||||
let header_hits = compute_mobile_header_hit_areas(app, header_rect);
|
||||
|
||||
|
|
@ -424,6 +429,7 @@ pub fn render_with_runtime_registry(
|
|||
|
||||
// Ambient notifications sit above panes, but below interactive overlays.
|
||||
render_notifications(app, frame, terminal_area);
|
||||
render_popup_pane(app, terminal_runtimes, frame, terminal_area);
|
||||
|
||||
match app.mode {
|
||||
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
|
||||
|
|
@ -1392,6 +1398,8 @@ mod tests {
|
|||
command: "lazygit".to_string(),
|
||||
action: crate::config::CustomCommandAction::Pane,
|
||||
description: Some("open lazygit".to_string()),
|
||||
width: None,
|
||||
height: None,
|
||||
},
|
||||
crate::config::CustomCommandKeybind {
|
||||
bindings: crate::config::ActionKeybinds::prefix("alt+h"),
|
||||
|
|
@ -1399,6 +1407,8 @@ mod tests {
|
|||
command: "echo hello".to_string(),
|
||||
action: crate::config::CustomCommandAction::Shell,
|
||||
description: None,
|
||||
width: None,
|
||||
height: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue