From 56edcbcba83b8ff7f6f69ee4231f163e97df3346 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 6 Jul 2026 23:06:00 +0300 Subject: [PATCH] feat: expose pane scroll state --- docs/next/CHANGELOG.md | 1 + docs/next/api/herdr-api.schema.json | 136 +++++++++++++++++- .../src/content/docs/cli-reference.mdx | 2 + .../website/src/content/docs/socket-api.mdx | 17 ++- src/api/schema/events.rs | 14 +- src/api/schema/panes.rs | 9 ++ src/api/schema/tests.rs | 33 ++++- src/api/server.rs | 1 + src/api/subscriptions.rs | 115 ++++++++++++++- src/app/api/panes.rs | 42 ++++++ src/app/creation.rs | 10 ++ 11 files changed, 373 insertions(+), 7 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 6d2348ae..8701dd24 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `session.snapshot` to bootstrap client runtime state in one socket API response before subscribing to events. - Added `herdr api schema` to inspect the bundled socket API schema, with `--json` for the full JSON Schema document and `--output PATH` for file output. - Added `layout.updated` socket events so protocol clients can keep tab layout snapshots current after pane split, resize, swap, move, zoom, and layout mutations. +- Added pane scroll metrics to pane socket API responses and `pane.scroll_changed` subscriptions for clients that need to show when a pane is scrolled back. - Added `herdr terminal session observe` for read-only live ANSI terminal streams that bridge processes can consume as newline-delimited JSON. - Added `herdr terminal session control` for bridge processes that need live ANSI frames plus input, resize, scroll, release, and takeover authority. - Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 4b33e750..ef6a6fdb 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -745,6 +745,16 @@ "minimum": 0, "type": "integer" }, + "scroll": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/PaneScrollInfo" + }, + { + "type": "null" + } + ] + }, "state_labels": { "additionalProperties": { "type": "string" @@ -897,6 +907,31 @@ ], "type": "object" }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, "SplitDirection": { "enum": [ "right", @@ -3354,6 +3389,22 @@ ], "type": "object" }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane.scroll_changed", + "type": "string" + } + }, + "required": [ + "type", + "pane_id" + ], + "type": "object" + }, { "properties": { "type": { @@ -5093,6 +5144,50 @@ ], "type": "object" }, + "PaneScrollChangedEvent": { + "properties": { + "pane_id": { + "type": "string" + }, + "scroll": { + "$ref": "#/schemas/subscription_event/$defs/PaneScrollInfo" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "workspace_id", + "scroll" + ], + "type": "object" + }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, "ReadFormat": { "enum": [ "text", @@ -5116,13 +5211,17 @@ }, { "$ref": "#/schemas/subscription_event/$defs/PaneAgentStatusChangedEvent" + }, + { + "$ref": "#/schemas/subscription_event/$defs/PaneScrollChangedEvent" } ] }, "SubscriptionEventKind": { "enum": [ "pane.output_matched", - "pane.agent_status_changed" + "pane.agent_status_changed", + "pane.scroll_changed" ], "type": "string" } @@ -6377,6 +6476,16 @@ "minimum": 0, "type": "integer" }, + "scroll": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PaneScrollInfo" + }, + { + "type": "null" + } + ] + }, "state_labels": { "additionalProperties": { "type": "string" @@ -6811,6 +6920,31 @@ ], "type": "object" }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, "PaneSwapReason": { "enum": [ "no_neighbor", diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index a6870fff..81fe4dce 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -224,6 +224,8 @@ herdr pane report-agent \ Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The existing `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. +`pane get` and `pane list` include `scroll` when terminal scroll metrics are available. `scroll.offset_from_bottom == 0` means the pane is at the bottom of its scrollback. + Report display-only pane metadata without taking over semantic state: ```bash diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 64c5ac4e..600238b9 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -150,6 +150,18 @@ like `ctrl+h`, `control+j`, `alt+x`, and `shift+tab`, function keys like Herdr returns that pane. When it is omitted, Herdr returns the active focused pane. +`PaneInfo` includes `scroll` when terminal scroll metrics are available: + +```json +{ + "offset_from_bottom": 12, + "max_offset_from_bottom": 240, + "viewport_rows": 30 +} +``` + +Clients can treat `offset_from_bottom == 0` as at-bottom state. + `pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`, `zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios. `pane.neighbor` and `pane.edges` include that same layout snapshot so clients @@ -630,7 +642,10 @@ Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`, that workspace. Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`, `pane.moved`, `pane.exited`, `pane.agent_detected`, -`pane.output_matched`, and `pane.agent_status_changed`. +`pane.output_matched`, `pane.agent_status_changed`, and `pane.scroll_changed`. +`pane.scroll_changed` is scoped to one `pane_id` and emits `pane_id`, +`workspace_id`, and the current `scroll` metrics whenever Herdr observes a +changed scroll snapshot. Layout event subscriptions include `layout.updated`. The event carries the updated `PaneLayoutSnapshot` for one tab. Clients that bootstrap with `session.snapshot` should replace the cached layout with the same diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index aff3d2a2..41b9907d 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::common::{AgentStatus, ReadSource}; -use super::panes::{PaneInfo, PaneReadResult}; +use super::panes::{PaneInfo, PaneReadResult, PaneScrollInfo}; use super::tabs::TabInfo; use super::workspaces::WorkspaceInfo; use super::worktrees::WorktreeInfo; @@ -72,6 +72,8 @@ pub enum Subscription { #[serde(default, skip_serializing_if = "Option::is_none")] agent_status: Option, }, + #[serde(rename = "pane.scroll_changed")] + PaneScrollChanged { pane_id: String }, #[serde(rename = "layout.updated")] LayoutUpdated {}, } @@ -350,6 +352,8 @@ pub enum SubscriptionEventKind { PaneOutputMatched, #[serde(rename = "pane.agent_status_changed")] PaneAgentStatusChanged, + #[serde(rename = "pane.scroll_changed")] + ScrollChanged, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -363,6 +367,7 @@ pub struct SubscriptionEventEnvelope { pub enum SubscriptionEventData { PaneOutputMatched(PaneOutputMatchedEvent), PaneAgentStatusChanged(PaneAgentStatusChangedEvent), + ScrollChanged(PaneScrollChangedEvent), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -389,6 +394,13 @@ pub struct PaneAgentStatusChangedEvent { pub state_labels: HashMap, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneScrollChangedEvent { + pub pane_id: String, + pub workspace_id: String, + pub scroll: PaneScrollInfo, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EventData { diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index ef45a7ca..42a640ef 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -364,9 +364,18 @@ pub struct PaneInfo { pub state_labels: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scroll: Option, pub revision: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneScrollInfo { + pub offset_from_bottom: u64, + pub max_offset_from_bottom: u64, + pub viewport_rows: u64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PaneProcessInfo { pub pane_id: String, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 8bb876d0..5f603a18 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -430,6 +430,10 @@ fn subscribe_request_parses_parameterized_subscriptions() { "type": "pane.agent_status_changed", "pane_id": "p_1_1", "agent_status": "done" + }, + { + "type": "pane.scroll_changed", + "pane_id": "p_1_1" } ] } @@ -440,7 +444,7 @@ fn subscribe_request_parses_parameterized_subscriptions() { let Method::EventsSubscribe(params) = request.method else { panic!("wrong method parsed"); }; - assert_eq!(params.subscriptions.len(), 2); + assert_eq!(params.subscriptions.len(), 3); assert!(matches!( ¶ms.subscriptions[0], Subscription::PaneOutputMatched { @@ -458,6 +462,10 @@ fn subscribe_request_parses_parameterized_subscriptions() { agent_status: Some(AgentStatus::Done), } if pane_id == "p_1_1" )); + assert!(matches!( + ¶ms.subscriptions[2], + Subscription::PaneScrollChanged { pane_id } if pane_id == "p_1_1" + )); } #[test] @@ -486,6 +494,27 @@ fn subscription_event_envelope_round_trips() { assert_eq!(restored, event); } +#[test] +fn scroll_changed_subscription_event_round_trips() { + let event = SubscriptionEventEnvelope { + event: SubscriptionEventKind::ScrollChanged, + data: SubscriptionEventData::ScrollChanged(PaneScrollChangedEvent { + pane_id: "p_1_1".into(), + workspace_id: "w_1".into(), + scroll: PaneScrollInfo { + offset_from_bottom: 12, + max_offset_from_bottom: 240, + viewport_rows: 30, + }, + }), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"event\":\"pane.scroll_changed\"")); + let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); +} + #[test] fn success_response_round_trips() { let response = SuccessResponse { @@ -600,6 +629,7 @@ fn worktree_request_and_response_round_trip() { custom_status: None, state_labels: HashMap::new(), agent_session: None, + scroll: None, revision: 0, }, worktree: WorktreeInfo { @@ -1007,6 +1037,7 @@ fn create_response_round_trips_with_root_pane() { custom_status: None, state_labels: HashMap::new(), agent_session: None, + scroll: None, revision: 0, }, }, diff --git a/src/api/server.rs b/src/api/server.rs index df0f8fc3..67093c72 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -674,6 +674,7 @@ mod tests { custom_status: None, state_labels: HashMap::new(), agent_session: None, + scroll: None, revision: 0, } } diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 73586b99..b3a369c2 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -1,8 +1,9 @@ use regex::Regex; use crate::api::schema::{ - ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request, - Subscription, SubscriptionEventData, SubscriptionEventEnvelope, SubscriptionEventKind, + ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, + PaneScrollChangedEvent, PaneScrollInfo, Request, Subscription, SubscriptionEventData, + SubscriptionEventEnvelope, SubscriptionEventKind, }; use crate::api::server::{dispatch_to_app_with_timeout, APP_RESPONSE_TIMEOUT}; use crate::api::{ApiRequestSender, EventHub}; @@ -55,6 +56,12 @@ pub(super) struct ActiveAgentStatusChangedSubscription { request_prefix: String, } +pub(super) struct ActiveScrollChangedSubscription { + pane_id: String, + last_scroll: Option, + request_prefix: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PanePresentationSnapshot { title: Option, @@ -97,6 +104,7 @@ pub(super) enum ActiveSubscription { Event(ActiveEventSubscription), OutputMatched(ActiveOutputMatchedSubscription), AgentStatusChanged(Box), + ScrollChanged(ActiveScrollChangedSubscription), } impl ActiveSubscription { @@ -269,6 +277,15 @@ impl ActiveSubscription { }, ))) } + Subscription::PaneScrollChanged { pane_id } => { + let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?; + + Ok(Self::ScrollChanged(ActiveScrollChangedSubscription { + pane_id: probe.pane_id, + last_scroll: probe.scroll, + request_prefix: format!("{request_id}:sub:{index}"), + })) + } } } @@ -285,6 +302,9 @@ impl ActiveSubscription { Self::AgentStatusChanged(subscription) => { serde_json::to_value(subscription.poll(api_tx, event_hub)?).ok() } + Self::ScrollChanged(subscription) => { + serde_json::to_value(subscription.poll(api_tx)?).ok() + } } } } @@ -466,6 +486,39 @@ impl ActiveAgentStatusChangedSubscription { } } +impl ActiveScrollChangedSubscription { + fn poll(&mut self, api_tx: &ApiRequestSender) -> Option { + let pane = pane_get( + format!("{}:pane", self.request_prefix), + &self.pane_id, + api_tx, + ) + .ok()?; + self.event_from_snapshot(pane) + } + + fn event_from_snapshot( + &mut self, + pane: crate::api::schema::PaneInfo, + ) -> Option { + let scroll = pane.scroll; + if self.last_scroll == scroll { + return None; + } + self.last_scroll = scroll; + let scroll = scroll?; + + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::ScrollChanged, + data: SubscriptionEventData::ScrollChanged(PaneScrollChangedEvent { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + scroll, + }), + }) + } +} + fn pane_read( request_id: String, pane_id: &str, @@ -558,7 +611,7 @@ mod tests { use std::collections::HashMap; use super::*; - use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind}; + use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo}; fn status_event(custom_status: Option<&str>) -> EventEnvelope { EventEnvelope { @@ -576,6 +629,62 @@ mod tests { } } + fn pane_info_with_scroll(scroll: Option) -> PaneInfo { + PaneInfo { + pane_id: "pane_1".into(), + terminal_id: "terminal_1".into(), + workspace_id: "workspace_1".into(), + tab_id: "tab_1".into(), + focused: true, + cwd: None, + foreground_cwd: None, + label: None, + agent: None, + title: None, + display_agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + state_labels: HashMap::new(), + agent_session: None, + scroll, + revision: 0, + } + } + + #[test] + fn scroll_subscription_emits_when_scroll_snapshot_changes() { + let at_bottom = PaneScrollInfo { + offset_from_bottom: 0, + max_offset_from_bottom: 40, + viewport_rows: 20, + }; + let scrolled_back = PaneScrollInfo { + offset_from_bottom: 8, + max_offset_from_bottom: 40, + viewport_rows: 20, + }; + let mut subscription = ActiveScrollChangedSubscription { + pane_id: "pane_1".into(), + last_scroll: Some(at_bottom), + request_prefix: "test".into(), + }; + + assert!(subscription + .event_from_snapshot(pane_info_with_scroll(Some(at_bottom))) + .is_none()); + + let event = subscription + .event_from_snapshot(pane_info_with_scroll(Some(scrolled_back))) + .expect("scroll event"); + assert_eq!(event.event, SubscriptionEventKind::ScrollChanged); + let SubscriptionEventData::ScrollChanged(data) = event.data else { + panic!("wrong event data"); + }; + assert_eq!(data.pane_id, "pane_1"); + assert_eq!(data.workspace_id, "workspace_1"); + assert_eq!(data.scroll, scrolled_back); + } + #[test] fn agent_status_subscription_replays_queued_metadata_set_and_expiry_events() { let event_hub = EventHub::default(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index c4e6321d..60b2e93b 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1877,6 +1877,22 @@ mod tests { (app, public_pane_id, rx) } + fn app_with_scrollback_runtime() -> (App, String, PaneId) { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let lines = (0..20) + .map(|line| format!("line {line:02}\n")) + .collect::(); + let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + lines.as_bytes(), + ); + app.state.insert_test_runtime(pane_id, runtime); + (app, public_pane_id, pane_id) + } + fn metadata_params(pane_id: String) -> PaneReportMetadataParams { PaneReportMetadataParams { pane_id, @@ -1928,6 +1944,32 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_pane_get_exposes_scroll_metrics() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.scroll_up(3); + + let response = app.handle_pane_get( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + let scroll = pane.scroll.expect("scroll metrics"); + assert_eq!(scroll.offset_from_bottom, 3); + assert!(scroll.max_offset_from_bottom >= scroll.offset_from_bottom); + assert_eq!(scroll.viewport_rows, 5); + } + #[tokio::test] async fn api_pane_send_keys_preserves_legacy_control_c_aliases() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(3); diff --git a/src/app/creation.rs b/src/app/creation.rs index bfc4cd03..13fc0a8e 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -375,6 +375,15 @@ impl App { let pane = ws.pane_state(pane_id)?; let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; let tab_idx = ws.find_tab_index_for_pane(pane_id)?; + let scroll = self + .state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + .and_then(|runtime| runtime.scroll_metrics()) + .map(|metrics| crate::api::schema::PaneScrollInfo { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + }); let focused = self.state.active == Some(ws_idx) && ws.active_tab == tab_idx && ws @@ -401,6 +410,7 @@ impl App { custom_status: presentation.custom_status, state_labels: presentation.state_labels, agent_session: terminal_agent_session_info(terminal), + scroll, revision: terminal.revision, }) }