mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: expose pane zoom api
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
### Added
|
||||
- Added `herdr integration install droid` for Factory Droid hooks that report session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Droid panes with `droid --resume <id>`.
|
||||
- Added directional pane swap with `prefix+shift+h/j/k/l`, a pane context-menu swap action, pane layout/neighbor/edge/focus/resize socket APIs, matching CLI commands, and optional `pane split --ratio` support.
|
||||
- Added `herdr pane zoom` and the `pane.zoom` socket API to toggle, set, or clear tab-local pane zoom from scripts and integrations.
|
||||
|
||||
## [0.6.8] - 2026-06-04
|
||||
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ not a gui window, not a web dashboard, not electron. herdr runs inside whatever
|
||||
|
||||
## agents can use herdr too
|
||||
|
||||
The local Unix socket lets agents create workspaces, split panes, spawn helpers, read output, and wait for state changes. Start with the [socket API docs](https://herdr.dev/docs/socket-api/) and [`SKILL.md`](./SKILL.md).
|
||||
The local Unix socket lets agents create workspaces, split or zoom panes, spawn helpers, read output, and wait for state changes. Start with the [socket API docs](https://herdr.dev/docs/socket-api/) and [`SKILL.md`](./SKILL.md).
|
||||
|
||||
## supported agents
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ herdr pane neighbor --direction left|right|up|down [--pane ID|--current]
|
||||
herdr pane edges [--pane ID|--current]
|
||||
herdr pane focus --direction left|right|up|down [--pane ID|--current]
|
||||
herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]
|
||||
herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off]
|
||||
herdr pane rename <pane_id> <label>|--clear
|
||||
herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]
|
||||
herdr pane swap --direction left|right|up|down [--pane ID|--current]
|
||||
|
||||
@@ -58,6 +58,7 @@ herdr pane layout --current
|
||||
herdr pane neighbor --direction right --current
|
||||
herdr pane resize --direction right --amount 0.1 --current
|
||||
herdr pane swap --direction right --current
|
||||
herdr pane zoom --on --current
|
||||
herdr pane split 1-1 --direction right --ratio 0.333
|
||||
```
|
||||
|
||||
@@ -83,7 +84,7 @@ Raw socket method names use dot notation:
|
||||
| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` |
|
||||
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
|
||||
| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` |
|
||||
| Pane | `pane.split`, `pane.swap`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
|
||||
| Pane | `pane.split`, `pane.swap`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
|
||||
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` |
|
||||
| Events | `events.subscribe`, `events.wait` |
|
||||
| Integrations | `integration.install`, `integration.uninstall` |
|
||||
@@ -99,6 +100,7 @@ the server's active focused pane.
|
||||
{"id":"req_edges","method":"pane.edges","params":{"pane_id":"1-1"}}
|
||||
{"id":"req_focus","method":"pane.focus_direction","params":{"direction":"right"}}
|
||||
{"id":"req_resize","method":"pane.resize","params":{"pane_id":"1-1","direction":"right","amount":0.1}}
|
||||
{"id":"req_zoom","method":"pane.zoom","params":{"pane_id":"1-1","mode":"toggle"}}
|
||||
{"id":"req_split","method":"pane.split","params":{"direction":"right","ratio":0.333}}
|
||||
```
|
||||
|
||||
@@ -121,6 +123,20 @@ running processes. The response is `type: "pane_swap"` with `changed`, optional
|
||||
`cross_tab`. When a tab is zoomed, swap keeps zoom active and mutates the hidden
|
||||
full-tab layout.
|
||||
|
||||
`pane.zoom` toggles, enables, or disables zoom for the target pane's tab:
|
||||
|
||||
```json
|
||||
{"id":"req_zoom_toggle","method":"pane.zoom","params":{"pane_id":"1-1"}}
|
||||
{"id":"req_zoom_on","method":"pane.zoom","params":{"pane_id":"1-1","mode":"on"}}
|
||||
{"id":"req_zoom_off","method":"pane.zoom","params":{"pane_id":"1-1","mode":"off"}}
|
||||
```
|
||||
|
||||
Omitting `pane_id` targets the server's active focused pane. The response is
|
||||
`type: "pane_zoom"` with `changed`, `zoom_changed`, `focus_changed`, optional
|
||||
`reason`, `pane_id`, `focused_pane_id`, `zoomed`, and `layout`. `changed` is
|
||||
true when either zoom state or focus changed. Reason values are `single_pane`,
|
||||
`already_zoomed`, and `already_unzoomed`.
|
||||
|
||||
Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch.
|
||||
|
||||
Create a worktree from a source workspace:
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
+9
-351
@@ -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,
|
||||
},
|
||||
@@ -923,142 +729,6 @@ pub struct AgentInfo {
|
||||
pub revision: 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 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,
|
||||
@@ -1067,18 +737,6 @@ pub struct AgentSessionInfo {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+87
-10
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+288
-2
@@ -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(¶ms.pane_id) else {
|
||||
return pane_not_found(id, ¶ms.pane_id);
|
||||
@@ -1280,6 +1332,240 @@ mod tests {
|
||||
assert_eq!(swap.layout.workspace_id, app.public_workspace_id(0));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
+107
-2
@@ -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!(
|
||||
@@ -1049,6 +1123,37 @@ mod tests {
|
||||
assert!(err.contains("usage: herdr pane swap"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
Reference in New Issue
Block a user