Skip to main content

agentmux_srv/backend/viewer/
panes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The panes a paired device lists (`GET /agentmux/viewer/panes`): every pane
5//! of every window and tab, in the order the desktop shows them, plus each
6//! agent's shell drawer (a headless terminal named by the agent block's
7//! `term:shellsubblockid`, not in any tab). Names, types and whether they
8//! run; never a command line, a working directory or any content.
9//!
10//! An agent hidden from paired devices is left out, and so is its shell
11//! drawer. Pure over the object tree and the lookups in [`PaneFacts`], so it
12//! is tested without controllers or a registry.
13
14use serde::Serialize;
15
16use crate::backend::blockcontroller::META_KEY_SHELL_SUBBLOCK_ID;
17use crate::backend::obj::{self, Block, Tab, Window, Workspace};
18
19/// What a device can do with a pane.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum PaneKind {
23    /// An agent: its feed opens by `agent`.
24    Agent,
25    /// A terminal.
26    Terminal,
27    /// Any other pane (editor, browser, files, a widget, ...): listed, opened
28    /// on the computer.
29    Other,
30}
31
32/// One pane, as a device gets it.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct PaneEntry {
35    pub block_id: String,
36    /// The desktop's view id (`agent`, `term`, `editor`, `ext:<id>`, ...).
37    pub view: String,
38    pub kind: PaneKind,
39    pub label: String,
40    pub window_id: String,
41    pub window: String,
42    pub tab_id: String,
43    pub tab: String,
44    pub tab_active: bool,
45    /// For an agent: the name its feed route takes, while it is registered.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub agent: Option<String>,
48    /// For a terminal: whether its shell is running.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub running: Option<bool>,
51    /// For a shell drawer: the agent pane it belongs to.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub parent_block_id: Option<String>,
54}
55
56/// The live facts the object tree doesn't hold.
57pub struct PaneFacts<'a> {
58    /// An agent's or terminal's label, and whether it is an agent (Tower's
59    /// `block_label`). Any other pane is labelled by its own title, or not
60    /// at all (the device names it by its view).
61    pub label: &'a dyn Fn(&Block) -> (String, bool),
62    /// The name a registered agent runs under in this block.
63    pub agent_name: &'a dyn Fn(&str) -> Option<String>,
64    /// Whether a terminal block's shell is running; `None` without a controller.
65    pub running: &'a dyn Fn(&str) -> Option<bool>,
66    /// Whether the agent in this block is hidden from paired devices.
67    pub hidden: &'a dyn Fn(&str) -> bool,
68}
69
70/// Every pane, window by window, tab by tab (pinned tabs first, as shown), each
71/// agent's shell drawer right after its agent.
72pub fn list(windows: &[Window], workspaces: &[Workspace], tabs: &[Tab], blocks: &[Block], facts: &PaneFacts) -> Vec<PaneEntry> {
73    let block = |id: &str| blocks.iter().find(|b| b.oid == id);
74    let mut out = Vec::new();
75    for window in windows {
76        let Some(ws) = workspaces.iter().find(|w| w.oid == window.workspaceid) else { continue };
77        let window_name = obj::meta_get_string(&window.meta, "window:displayname", "");
78        for tab in ws.pinnedtabids.iter().chain(ws.tabids.iter()).filter_map(|id| tabs.iter().find(|t| &t.oid == id)) {
79            let entry = |b: &Block, kind: PaneKind, label: String| PaneEntry {
80                block_id: b.oid.clone(),
81                view: obj::meta_get_string(&b.meta, "view", ""),
82                kind,
83                label,
84                window_id: window.oid.clone(),
85                window: window_name.clone(),
86                tab_id: tab.oid.clone(),
87                tab: tab.name.clone(),
88                tab_active: ws.activetabid == tab.oid,
89                agent: None,
90                running: None,
91                parent_block_id: None,
92            };
93            for b in tab.blockids.iter().filter_map(|id| block(id)) {
94                let (label, agent) = (facts.label)(b);
95                let view = obj::meta_get_string(&b.meta, "view", "");
96                if agent || view == "agent" {
97                    if (facts.hidden)(&b.oid) {
98                        continue;
99                    }
100                    // An agent pane with no agent in it yet: `block_label`
101                    // doesn't know it as an agent and calls it "Terminal".
102                    // Its own title, or none (the device says "Agent").
103                    let label = if agent { label } else { obj::meta_get_string(&b.meta, "frame:title", "") };
104                    out.push(PaneEntry { agent: (facts.agent_name)(&b.oid), ..entry(b, PaneKind::Agent, label.clone()) });
105                    let drawer = obj::meta_get_string(&b.meta, META_KEY_SHELL_SUBBLOCK_ID, "");
106                    if let Some(shell) = block(&drawer).filter(|_| !drawer.is_empty()) {
107                        let shell_label = if label.is_empty() { "Agent shell".to_string() } else { format!("{label} shell") };
108                        out.push(PaneEntry {
109                            running: (facts.running)(&shell.oid),
110                            parent_block_id: Some(b.oid.clone()),
111                            ..entry(shell, PaneKind::Terminal, shell_label)
112                        });
113                    }
114                } else if view == "term" {
115                    out.push(PaneEntry { running: (facts.running)(&b.oid), ..entry(b, PaneKind::Terminal, label) });
116                } else {
117                    // `block_label` names terminals; it would call an
118                    // untitled editor "Terminal".
119                    out.push(entry(b, PaneKind::Other, obj::meta_get_string(&b.meta, "frame:title", "")));
120                }
121            }
122        }
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn block(id: &str, view: &str, extra: &[(&str, &str)]) -> Block {
132        let mut b = Block { oid: id.into(), ..Default::default() };
133        b.meta.insert("view".into(), serde_json::json!(view));
134        for (k, v) in extra {
135            b.meta.insert((*k).into(), serde_json::json!(v));
136        }
137        b
138    }
139
140    fn tab(id: &str, name: &str, blocks: &[&str]) -> Tab {
141        Tab { oid: id.into(), name: name.into(), blockids: blocks.iter().map(|s| s.to_string()).collect(), ..Default::default() }
142    }
143
144    fn label(b: &Block) -> (String, bool) {
145        let title = obj::meta_get_string(&b.meta, "frame:title", "");
146        let agent = obj::meta_get_string(&b.meta, "agentName", "");
147        if !agent.is_empty() {
148            (agent, true)
149        } else {
150            (if title.is_empty() { "Terminal".into() } else { title }, false)
151        }
152    }
153
154    fn tree() -> (Vec<Window>, Vec<Workspace>, Vec<Tab>, Vec<Block>) {
155        let mut window = Window { oid: "w1".into(), workspaceid: "ws1".into(), ..Default::default() };
156        window.meta.insert("window:displayname".into(), serde_json::json!("Main"));
157        let ws = Workspace {
158            oid: "ws1".into(),
159            tabids: vec!["t2".into()],
160            pinnedtabids: vec!["t1".into()],
161            activetabid: "t2".into(),
162            ..Default::default()
163        };
164        let tabs = vec![tab("t1", "Pinned", &["ed", "wid"]), tab("t2", "Work", &["ag", "hid", "sh", "gone"])];
165        let blocks = vec![
166            block("ed", "editor", &[("frame:title", "notes.md")]),
167            block("wid", "ext:acme.board", &[]),
168            block("ag", "agent", &[("agentName", "Ada"), (META_KEY_SHELL_SUBBLOCK_ID, "drawer")]),
169            block("drawer", "term", &[]),
170            block("hid", "agent", &[("agentName", "Secret"), (META_KEY_SHELL_SUBBLOCK_ID, "hid-drawer")]),
171            block("hid-drawer", "term", &[]),
172            block("sh", "term", &[("frame:title", "build")]),
173        ];
174        (vec![window], vec![ws], tabs, blocks)
175    }
176
177    #[test]
178    fn every_pane_in_order_with_drawers_after_their_agents_and_hidden_agents_left_out() {
179        let (windows, workspaces, tabs, blocks) = tree();
180        let facts = PaneFacts {
181            label: &label,
182            agent_name: &|id| (id == "ag").then(|| "ada".to_string()),
183            running: &|id| match id {
184                "drawer" => Some(true),
185                "sh" => Some(false),
186                _ => None,
187            },
188            hidden: &|id| id == "hid",
189        };
190        let panes = list(&windows, &workspaces, &tabs, &blocks, &facts);
191        let ids: Vec<&str> = panes.iter().map(|p| p.block_id.as_str()).collect();
192        assert_eq!(ids, ["ed", "wid", "ag", "drawer", "sh"], "pinned tab first; a missing block is skipped");
193
194        assert_eq!(panes[0].kind, PaneKind::Other);
195        assert_eq!((panes[0].label.as_str(), panes[0].tab.as_str(), panes[0].tab_active), ("notes.md", "Pinned", false));
196        assert_eq!(panes[0].window, "Main");
197
198        assert_eq!((panes[1].kind, panes[1].label.as_str()), (PaneKind::Other, ""), "an untitled widget isn't called Terminal");
199
200        assert_eq!(panes[2].kind, PaneKind::Agent);
201        assert_eq!(panes[2].agent.as_deref(), Some("ada"));
202        assert!(panes[2].tab_active);
203
204        assert_eq!(panes[3].kind, PaneKind::Terminal);
205        assert_eq!(panes[3].label, "Ada shell");
206        assert_eq!(panes[3].parent_block_id.as_deref(), Some("ag"));
207        assert_eq!(panes[3].running, Some(true));
208        assert_eq!(panes[3].tab_id, "t2", "the drawer is shown where its agent is");
209
210        assert_eq!((panes[4].kind, panes[4].running), (PaneKind::Terminal, Some(false)));
211        assert!(!ids.contains(&"hid-drawer"), "a hidden agent's shell is hidden with it");
212    }
213
214    #[test]
215    fn the_wire_omits_what_a_pane_does_not_have() {
216        let (windows, workspaces, tabs, blocks) = tree();
217        let facts = PaneFacts { label: &label, agent_name: &|_| None, running: &|_| None, hidden: &|_| false };
218        let panes = list(&windows, &workspaces, &tabs, &blocks, &facts);
219        let editor = serde_json::to_value(&panes[0]).unwrap();
220        assert_eq!(editor["kind"], "other");
221        assert_eq!(editor["view"], "editor");
222        for absent in ["agent", "running", "parent_block_id"] {
223            assert!(editor.get(absent).is_none(), "{absent}");
224        }
225        let agent = serde_json::to_value(panes.iter().find(|p| p.block_id == "ag").unwrap()).unwrap();
226        assert_eq!(agent["kind"], "agent");
227        assert!(agent.get("agent").is_none(), "an agent that is not registered has no feed name");
228    }
229
230    #[test]
231    fn an_agent_pane_with_no_agent_yet_is_not_called_terminal() {
232        let mut window = Window { oid: "w".into(), workspaceid: "ws".into(), ..Default::default() };
233        window.meta.insert("window:displayname".into(), serde_json::json!(""));
234        let ws = Workspace { oid: "ws".into(), tabids: vec!["t".into()], activetabid: "t".into(), ..Default::default() };
235        let tabs = vec![tab("t", "Tab 1", &["empty"])];
236        // What a fresh window opens with: an agent pane nobody started an
237        // agent in. `block_label` calls it "Terminal" (seen on a phone).
238        let blocks = vec![block("empty", "agent", &[(META_KEY_SHELL_SUBBLOCK_ID, "sh")]), block("sh", "term", &[])];
239        let facts = PaneFacts {
240            label: &|_| ("Terminal".to_string(), false),
241            agent_name: &|_| None,
242            running: &|_| None,
243            hidden: &|_| false,
244        };
245        let panes = list(&[window], &[ws], &tabs, &blocks, &facts);
246        assert_eq!((panes[0].kind, panes[0].label.as_str()), (PaneKind::Agent, ""), "the device names it Agent");
247        assert_eq!(panes[1].label, "Agent shell");
248    }
249}