aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 00:41:19 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 00:41:19 +0300
commitb7619db085eb4054a071b51159eda65d6b60f3fe (patch)
tree19e6885b47b67dcad5beefa1ca487de170085dcd /src
parent556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (diff)
feat: expose pane zoom api
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs360
-rw-r--r--src/api/schema/panes.rs394
-rw-r--r--src/api/server.rs1
-rw-r--r--src/app/actions.rs97
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs290
-rw-r--r--src/cli/pane.rs109
8 files changed, 888 insertions, 365 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index f0cf42c7..7d2d5bc1 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -38,6 +38,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::AgentStart(_)
| Method::PaneSplit(_)
| Method::PaneSwap(_)
+ | Method::PaneZoom(_)
| Method::PaneFocusDirection(_)
| Method::PaneResize(_)
| Method::PaneRename(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index ba2fd1e5..c0715758 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -2,6 +2,10 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
+pub mod panes;
+
+pub use panes::*;
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
pub id: String,
@@ -70,6 +74,8 @@ pub enum Method {
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.swap")]
PaneSwap(PaneSwapParams),
+ #[serde(rename = "pane.zoom")]
+ PaneZoom(PaneZoomParams),
#[serde(rename = "pane.layout")]
PaneLayout(PaneLayoutParams),
#[serde(rename = "pane.neighbor")]
@@ -274,118 +280,6 @@ pub struct AgentStartParams {
pub argv: Vec<String>,
}
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneSplitParams {
- #[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>,
- pub direction: SplitDirection,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub ratio: Option<f32>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub cwd: Option<String>,
- #[serde(default)]
- pub focus: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SplitDirection {
- Right,
- Down,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum PaneDirection {
- Left,
- Right,
- Up,
- Down,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PaneSwapParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub direction: Option<PaneDirection>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub source_pane_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub target_pane_id: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PaneLayoutParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneNeighborParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
- pub direction: PaneDirection,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PaneEdgesParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneFocusDirectionParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
- pub direction: PaneDirection,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneResizeParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub pane_id: Option<String>,
- pub direction: PaneDirection,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub amount: Option<f32>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PaneListParams {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub workspace_id: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneRenameParams {
- pub pane_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub label: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneSendTextParams {
- pub pane_id: String,
- pub text: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneSendKeysParams {
- pub pane_id: String,
- pub keys: Vec<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneSendInputParams {
- pub pane_id: String,
- #[serde(default, skip_serializing_if = "String::is_empty")]
- pub text: String,
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub keys: Vec<String>,
-}
-
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerLiveHandoffParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -396,97 +290,6 @@ pub struct ServerLiveHandoffParams {
pub expected_version: Option<String>,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReadParams {
- pub pane_id: String,
- pub source: ReadSource,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub lines: Option<u32>,
- #[serde(default)]
- pub format: ReadFormat,
- #[serde(default = "default_true")]
- pub strip_ansi: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReportAgentParams {
- pub pane_id: String,
- pub source: String,
- pub agent: String,
- pub state: PaneAgentState,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub message: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub seq: Option<u64>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session_path: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReportAgentSessionParams {
- pub pane_id: String,
- pub source: String,
- pub agent: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub seq: Option<u64>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session_id: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session_path: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReportMetadataParams {
- pub pane_id: String,
- pub source: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub applies_to_source: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub title: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub display_agent: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
- #[serde(default, skip_serializing_if = "HashMap::is_empty")]
- pub state_labels: HashMap<String, String>,
- #[serde(default)]
- pub clear_title: bool,
- #[serde(default)]
- pub clear_display_agent: bool,
- #[serde(default)]
- pub clear_custom_status: bool,
- #[serde(default)]
- pub clear_state_labels: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub seq: Option<u64>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub ttl_ms: Option<u64>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneClearAgentAuthorityParams {
- pub pane_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub source: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub seq: Option<u64>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReleaseAgentParams {
- pub pane_id: String,
- pub source: String,
- pub agent: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub seq: Option<u64>,
-}
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReadSource {
@@ -793,6 +596,9 @@ pub enum ResponseResult {
PaneSwap {
swap: PaneSwapResult,
},
+ PaneZoom {
+ zoom: PaneZoomResult,
+ },
PaneLayout {
layout: PaneLayoutSnapshot,
},
@@ -924,142 +730,6 @@ pub struct AgentInfo {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneInfo {
- pub pane_id: String,
- pub terminal_id: String,
- pub workspace_id: String,
- pub tab_id: String,
- pub focused: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub cwd: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub foreground_cwd: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub label: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub title: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub display_agent: Option<String>,
- pub agent_status: AgentStatus,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub custom_status: Option<String>,
- #[serde(default, skip_serializing_if = "HashMap::is_empty")]
- pub state_labels: HashMap<String, String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub agent_session: Option<AgentSessionInfo>,
- pub revision: u64,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneSwapResult {
- pub changed: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reason: Option<PaneSwapReason>,
- pub source_pane_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub target_pane_id: Option<String>,
- pub focused_pane_id: String,
- pub layout: PaneLayoutSnapshot,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum PaneSwapReason {
- NoNeighbor,
- SamePane,
- NotFound,
- CrossTab,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneLayoutSnapshot {
- pub workspace_id: String,
- pub tab_id: String,
- pub zoomed: bool,
- pub area: PaneLayoutRect,
- pub focused_pane_id: String,
- pub panes: Vec<PaneLayoutPane>,
- pub splits: Vec<PaneLayoutSplit>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneLayoutRect {
- pub x: u16,
- pub y: u16,
- pub width: u16,
- pub height: u16,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneLayoutPane {
- pub pane_id: String,
- pub focused: bool,
- pub rect: PaneLayoutRect,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneLayoutSplit {
- pub id: String,
- pub direction: SplitDirection,
- pub ratio: f32,
- pub rect: PaneLayoutRect,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneNeighborResult {
- pub pane_id: String,
- pub direction: PaneDirection,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub neighbor_pane_id: Option<String>,
- pub layout: PaneLayoutSnapshot,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneEdgesResult {
- pub pane_id: String,
- pub left: bool,
- pub right: bool,
- pub up: bool,
- pub down: bool,
- pub layout: PaneLayoutSnapshot,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneFocusDirectionResult {
- pub changed: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reason: Option<PaneFocusDirectionReason>,
- pub source_pane_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub focused_pane_id: Option<String>,
- pub layout: PaneLayoutSnapshot,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum PaneFocusDirectionReason {
- NoNeighbor,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct PaneResizeResult {
- pub changed: bool,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub reason: Option<PaneResizeReason>,
- pub pane_id: String,
- pub focused_pane_id: String,
- pub layout: PaneLayoutSnapshot,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum PaneResizeReason {
- Unchanged,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionInfo {
pub source: String,
pub agent: String,
@@ -1068,18 +738,6 @@ pub struct AgentSessionInfo {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneReadResult {
- pub pane_id: String,
- pub workspace_id: String,
- pub tab_id: String,
- pub source: ReadSource,
- pub format: ReadFormat,
- pub text: String,
- pub revision: u64,
- pub truncated: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationInstallResult {
pub messages: Vec<String>,
}
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
new file mode 100644
index 00000000..6a1827a2
--- /dev/null
+++ b/src/api/schema/panes.rs
@@ -0,0 +1,394 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::{AgentSessionInfo, AgentStatus, PaneAgentState, ReadFormat, ReadSource};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneSplitParams {
+ #[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>,
+ pub direction: SplitDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ratio: Option<f32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default)]
+ pub focus: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SplitDirection {
+ Right,
+ Down,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneDirection {
+ Left,
+ Right,
+ Up,
+ Down,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneSwapParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub direction: Option<PaneDirection>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneZoomParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ #[serde(default)]
+ pub mode: PaneZoomMode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneZoomMode {
+ #[default]
+ Toggle,
+ On,
+ Off,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneLayoutParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneNeighborParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneEdgesParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneFocusDirectionParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneResizeParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub amount: Option<f32>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneListParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub workspace_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneRenameParams {
+ pub pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSendTextParams {
+ pub pane_id: String,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSendKeysParams {
+ pub pane_id: String,
+ pub keys: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneSendInputParams {
+ pub pane_id: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub text: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub keys: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReadParams {
+ pub pane_id: String,
+ pub source: ReadSource,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lines: Option<u32>,
+ #[serde(default)]
+ pub format: ReadFormat,
+ #[serde(default = "super::default_true")]
+ pub strip_ansi: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportAgentParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+ pub state: PaneAgentState,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub message: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_path: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportAgentSessionParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_path: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportMetadataParams {
+ pub pane_id: String,
+ pub source: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub applies_to_source: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub state_labels: HashMap<String, String>,
+ #[serde(default)]
+ pub clear_title: bool,
+ #[serde(default)]
+ pub clear_display_agent: bool,
+ #[serde(default)]
+ pub clear_custom_status: bool,
+ #[serde(default)]
+ pub clear_state_labels: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ttl_ms: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneClearAgentAuthorityParams {
+ pub pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReleaseAgentParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneInfo {
+ pub pane_id: String,
+ pub terminal_id: String,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub focused: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub foreground_cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_agent: Option<String>,
+ pub agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub custom_status: Option<String>,
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub state_labels: HashMap<String, String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session: Option<AgentSessionInfo>,
+ pub revision: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneSwapResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneSwapReason>,
+ pub source_pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
+ pub focused_pane_id: String,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneSwapReason {
+ NoNeighbor,
+ SamePane,
+ NotFound,
+ CrossTab,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneZoomResult {
+ pub changed: bool,
+ pub zoom_changed: bool,
+ pub focus_changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneZoomReason>,
+ pub pane_id: String,
+ pub focused_pane_id: String,
+ pub zoomed: bool,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneZoomReason {
+ SinglePane,
+ AlreadyZoomed,
+ AlreadyUnzoomed,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneLayoutSnapshot {
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub zoomed: bool,
+ pub area: PaneLayoutRect,
+ pub focused_pane_id: String,
+ pub panes: Vec<PaneLayoutPane>,
+ pub splits: Vec<PaneLayoutSplit>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneLayoutRect {
+ pub x: u16,
+ pub y: u16,
+ pub width: u16,
+ pub height: u16,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneLayoutPane {
+ pub pane_id: String,
+ pub focused: bool,
+ pub rect: PaneLayoutRect,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneLayoutSplit {
+ pub id: String,
+ pub direction: SplitDirection,
+ pub ratio: f32,
+ pub rect: PaneLayoutRect,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneNeighborResult {
+ pub pane_id: String,
+ pub direction: PaneDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub neighbor_pane_id: Option<String>,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneEdgesResult {
+ pub pane_id: String,
+ pub left: bool,
+ pub right: bool,
+ pub up: bool,
+ pub down: bool,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneFocusDirectionResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneFocusDirectionReason>,
+ pub source_pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_id: Option<String>,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneFocusDirectionReason {
+ NoNeighbor,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneResizeResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneResizeReason>,
+ pub pane_id: String,
+ pub focused_pane_id: String,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneResizeReason {
+ Unchanged,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReadResult {
+ pub pane_id: String,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub source: ReadSource,
+ pub format: ReadFormat,
+ pub text: String,
+ pub revision: u64,
+ pub truncated: bool,
+}
diff --git a/src/api/server.rs b/src/api/server.rs
index 44bb3097..7e4dc3b1 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -296,6 +296,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentStart(_) => "agent.start",
Method::PaneSplit(_) => "pane.split",
Method::PaneSwap(_) => "pane.swap",
+ Method::PaneZoom(_) => "pane.zoom",
Method::PaneLayout(_) => "pane.layout",
Method::PaneNeighbor(_) => "pane.neighbor",
Method::PaneEdges(_) => "pane.edges",
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 8124be93..17daa6ff 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1350,6 +1350,28 @@ impl AppState {
// Pane operations
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum PaneZoomCommand {
+ Toggle,
+ On,
+ Off,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum PaneZoomNoopReason {
+ SinglePane,
+ AlreadyZoomed,
+ AlreadyUnzoomed,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct PaneZoomOutcome {
+ pub changed: bool,
+ pub focus_changed: bool,
+ pub reason: Option<PaneZoomNoopReason>,
+ pub zoomed: bool,
+}
+
impl AppState {
pub fn navigate_pane(&mut self, direction: NavDirection) {
let Some(ws_idx) = self.active else {
@@ -1468,17 +1490,72 @@ impl AppState {
}
}
- pub fn toggle_zoom(&mut self) {
- if let Some(tab) = self
- .active
- .and_then(|i| self.workspaces.get_mut(i))
- .and_then(|ws| ws.active_tab_mut())
- {
- if tab.layout.pane_count() > 1 {
- tab.zoomed = !tab.zoomed;
- self.mark_session_dirty();
- }
+ pub(crate) fn apply_pane_zoom(
+ &mut self,
+ ws_idx: usize,
+ pane_id: PaneId,
+ command: PaneZoomCommand,
+ ) -> Option<PaneZoomOutcome> {
+ let tab_idx = self
+ .workspaces
+ .get(ws_idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ let focus_changed = self.focus_pane_in_workspace(ws_idx, pane_id);
+ let tab = self
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))?;
+ if tab.layout.pane_count() <= 1 {
+ return Some(PaneZoomOutcome {
+ changed: false,
+ focus_changed,
+ reason: Some(PaneZoomNoopReason::SinglePane),
+ zoomed: tab.zoomed,
+ });
}
+
+ let desired = match command {
+ PaneZoomCommand::Toggle => !tab.zoomed,
+ PaneZoomCommand::On => true,
+ PaneZoomCommand::Off => false,
+ };
+ let reason = match (command, tab.zoomed) {
+ (PaneZoomCommand::On, true) => Some(PaneZoomNoopReason::AlreadyZoomed),
+ (PaneZoomCommand::Off, false) => Some(PaneZoomNoopReason::AlreadyUnzoomed),
+ _ => None,
+ };
+ if reason.is_some() {
+ return Some(PaneZoomOutcome {
+ changed: false,
+ focus_changed,
+ reason,
+ zoomed: tab.zoomed,
+ });
+ }
+
+ tab.zoomed = desired;
+ let zoomed = tab.zoomed;
+ self.mark_session_dirty();
+ Some(PaneZoomOutcome {
+ changed: true,
+ focus_changed,
+ reason: None,
+ zoomed,
+ })
+ }
+
+ pub fn toggle_zoom(&mut self) {
+ let Some(ws_idx) = self.active else {
+ return;
+ };
+ let Some(pane_id) = self
+ .workspaces
+ .get(ws_idx)
+ .and_then(crate::workspace::Workspace::focused_pane_id)
+ else {
+ return;
+ };
+ self.apply_pane_zoom(ws_idx, pane_id, PaneZoomCommand::Toggle);
}
pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool {
diff --git a/src/app/api.rs b/src/app/api.rs
index 35994bff..592ee7da 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -556,6 +556,7 @@ impl App {
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
+ Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params),
Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),
Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params),
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 23272ffd..d824d26b 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -8,9 +8,10 @@ use crate::api::schema::{
PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, ReadFormat, ReadSource,
- ResponseResult,
+ PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams,
+ PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
};
+use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason};
use crate::app::{App, Mode};
use crate::layout::{find_in_direction, NavDirection, PaneId};
@@ -517,6 +518,57 @@ impl App {
)
}
+ pub(super) fn handle_pane_zoom(&mut self, id: String, params: PaneZoomParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let command = match params.mode {
+ PaneZoomMode::Toggle => PaneZoomCommand::Toggle,
+ PaneZoomMode::On => PaneZoomCommand::On,
+ PaneZoomMode::Off => PaneZoomCommand::Off,
+ };
+ let Some(outcome) = self.state.apply_pane_zoom(ws_idx, pane_id, command) else {
+ return pane_not_found(id, &pane_public_id);
+ };
+ if outcome.changed || outcome.focus_changed {
+ self.schedule_session_save();
+ }
+ self.state.mode = Mode::Terminal;
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = layout.focused_pane_id.clone();
+
+ encode_success(
+ id,
+ ResponseResult::PaneZoom {
+ zoom: PaneZoomResult {
+ changed: outcome.changed || outcome.focus_changed,
+ zoom_changed: outcome.changed,
+ focus_changed: outcome.focus_changed,
+ reason: outcome.reason.map(|reason| match reason {
+ PaneZoomNoopReason::SinglePane => PaneZoomReason::SinglePane,
+ PaneZoomNoopReason::AlreadyZoomed => PaneZoomReason::AlreadyZoomed,
+ PaneZoomNoopReason::AlreadyUnzoomed => PaneZoomReason::AlreadyUnzoomed,
+ }),
+ pane_id: pane_public_id,
+ focused_pane_id,
+ zoomed: outcome.zoomed,
+ layout,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
@@ -1281,6 +1333,240 @@ mod tests {
}
#[test]
+ fn api_pane_zoom_current_toggles_zoom() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default());
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert_eq!(zoom.reason, None);
+ assert_eq!(zoom.pane_id, root_public);
+ assert_eq!(zoom.focused_pane_id, zoom.pane_id);
+ assert!(zoom.zoomed);
+ assert!(zoom.layout.zoomed);
+
+ let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default());
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert!(!zoom.zoomed);
+ assert!(!zoom.layout.zoomed);
+ }
+
+ #[test]
+ fn api_pane_zoom_explicit_background_pane_updates_focus_history() {
+ let mut app = app_with_linked_worktree();
+ app.state.workspaces.push(Workspace::test_new("other"));
+ let first = app.state.workspaces[0].tabs[0].root_pane;
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(first);
+ let target_public = app.public_pane_id(1, target).unwrap();
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(target_public.clone()),
+ mode: PaneZoomMode::On,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(zoom.zoom_changed);
+ assert!(zoom.focus_changed);
+ assert_eq!(zoom.pane_id, target_public);
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target));
+ assert!(app.state.workspaces[1].tabs[0].zoomed);
+
+ app.state.last_pane();
+
+ assert_eq!(app.state.active, Some(0));
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first));
+ }
+
+ #[test]
+ fn api_pane_zoom_single_pane_returns_noop() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(root_public.clone()),
+ mode: PaneZoomMode::Toggle,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(!zoom.changed);
+ assert!(!zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert_eq!(zoom.reason, Some(PaneZoomReason::SinglePane));
+ assert_eq!(zoom.pane_id, root_public);
+ assert!(!zoom.zoomed);
+ assert!(!app.state.workspaces[0].tabs[0].zoomed);
+ }
+
+ #[test]
+ fn api_pane_zoom_on_and_off_are_idempotent() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(root_public.clone()),
+ mode: PaneZoomMode::On,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert!(zoom.zoomed);
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(root_public.clone()),
+ mode: PaneZoomMode::On,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(!zoom.changed);
+ assert!(!zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed));
+ assert!(zoom.zoomed);
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(root_public),
+ mode: PaneZoomMode::Off,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert!(!zoom.zoomed);
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: None,
+ mode: PaneZoomMode::Off,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(!zoom.changed);
+ assert!(!zoom.zoom_changed);
+ assert!(!zoom.focus_changed);
+ assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyUnzoomed));
+ assert!(!zoom.zoomed);
+ }
+
+ #[test]
+ fn api_pane_zoom_idempotent_mode_reports_focus_change() {
+ let mut app = app_with_linked_worktree();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ app.state.workspaces[0].tabs[0].zoomed = true;
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_zoom(
+ "req".into(),
+ PaneZoomParams {
+ pane_id: Some(right_public),
+ mode: PaneZoomMode::On,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneZoom { zoom } = success.result else {
+ panic!("expected pane zoom response");
+ };
+ assert!(zoom.changed);
+ assert!(!zoom.zoom_changed);
+ assert!(zoom.focus_changed);
+ assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed));
+ assert!(zoom.zoomed);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
+ }
+
+ #[test]
+ fn api_pane_zoom_params_serialize_modes() {
+ let request = crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::PaneZoom(PaneZoomParams {
+ pane_id: Some("issue-1".into()),
+ mode: PaneZoomMode::On,
+ }),
+ };
+
+ let encoded = serde_json::to_string(&request).unwrap();
+ assert!(encoded.contains("\"method\":\"pane.zoom\""));
+ assert!(encoded.contains("\"mode\":\"on\""));
+
+ let decoded: crate::api::schema::Request = serde_json::from_str(&encoded).unwrap();
+ let crate::api::schema::Method::PaneZoom(params) = decoded.method else {
+ panic!("expected pane zoom request");
+ };
+ assert_eq!(params.pane_id, Some("issue-1".into()));
+ assert_eq!(params.mode, PaneZoomMode::On);
+ }
+
+ #[test]
fn api_pane_layout_returns_public_ids_rects_and_splits() {
let mut app = app_with_linked_worktree();
let root = app.state.workspaces[0].tabs[0].root_pane;
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 1a9b20e1..3fb83248 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -2,8 +2,8 @@ use crate::api::schema::{
Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams,
PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
- PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, ReadFormat, ReadSource,
- Request,
+ PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams,
+ ReadFormat, ReadSource, Request,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -20,6 +20,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"edges" => pane_edges(&args[1..]),
"focus" => pane_focus(&args[1..]),
"resize" => pane_resize(&args[1..]),
+ "zoom" => pane_zoom(&args[1..]),
"read" => pane_read(&args[1..]),
"rename" => pane_rename(&args[1..]),
"split" => pane_split(&args[1..]),
@@ -289,6 +290,78 @@ fn parse_pane_resize_args(args: &[String]) -> Result<PaneResizeParams, String> {
})
}
+fn pane_zoom(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_zoom_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:zoom".into(),
+ method: Method::PaneZoom(params),
+ })?)
+}
+
+fn parse_pane_zoom_args(args: &[String]) -> Result<PaneZoomParams, String> {
+ let mut pane_id = None;
+ let mut mode = PaneZoomMode::Toggle;
+ let mut mode_seen = false;
+
+ let mut index = 0;
+ if args
+ .first()
+ .is_some_and(|arg| !arg.as_str().starts_with("--"))
+ {
+ pane_id = args.first().map(|arg| super::normalize_pane_id(arg));
+ index = 1;
+ }
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
+ "--toggle" => {
+ if mode_seen {
+ return Err("provide only one of --toggle, --on, or --off".into());
+ }
+ mode = PaneZoomMode::Toggle;
+ mode_seen = true;
+ index += 1;
+ }
+ "--on" => {
+ if mode_seen {
+ return Err("provide only one of --toggle, --on, or --off".into());
+ }
+ mode = PaneZoomMode::On;
+ mode_seen = true;
+ index += 1;
+ }
+ "--off" => {
+ if mode_seen {
+ return Err("provide only one of --toggle, --on, or --off".into());
+ }
+ mode = PaneZoomMode::Off;
+ mode_seen = true;
+ index += 1;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+
+ Ok(PaneZoomParams { pane_id, mode })
+}
+
fn pane_rename(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!("usage: herdr pane rename <pane_id> <label>|--clear");
@@ -953,6 +1026,7 @@ fn print_pane_help() {
eprintln!(
" herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]"
);
+ eprintln!(" herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off]");
eprintln!(" herdr pane rename <pane_id> <label>|--clear");
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
eprintln!(
@@ -1050,6 +1124,37 @@ mod tests {
}
#[test]
+ fn parse_pane_zoom_args_defaults_to_current_toggle() {
+ let params = parse_pane_zoom_args(&args(&[])).unwrap();
+
+ assert_eq!(params.pane_id, None);
+ assert_eq!(params.mode, PaneZoomMode::Toggle);
+ }
+
+ #[test]
+ fn parse_pane_zoom_args_accepts_positional_pane_and_on() {
+ let params = parse_pane_zoom_args(&args(&["issue-1", "--on"])).unwrap();
+
+ assert_eq!(params.pane_id, Some("issue-1".into()));
+ assert_eq!(params.mode, PaneZoomMode::On);
+ }
+
+ #[test]
+ fn parse_pane_zoom_args_accepts_pane_option_and_off() {
+ let params = parse_pane_zoom_args(&args(&["--pane", "issue-2", "--off"])).unwrap();
+
+ assert_eq!(params.pane_id, Some("issue-2".into()));
+ assert_eq!(params.mode, PaneZoomMode::Off);
+ }
+
+ #[test]
+ fn parse_pane_zoom_args_rejects_multiple_modes() {
+ let err = parse_pane_zoom_args(&args(&["--on", "--off"])).unwrap_err();
+
+ assert!(err.contains("provide only one"));
+ }
+
+ #[test]
fn parse_pane_neighbor_args_accepts_directional_current() {
let params = parse_pane_neighbor_args(&args(&["--direction", "down"])).unwrap();