From b7ee7e415a0a95984ca4b7fe3ed45eb304daeb90 Mon Sep 17 00:00:00 2001 From: TSHOGX Date: Sat, 27 Jun 2026 10:30:45 +0800 Subject: [PATCH 1/3] feat(tmux): add floating agent-status popup (prefix + g) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a centered floating panel that lists agents across all sessions and lets you jump to one with Enter, opened via `tmux display-popup` and bound to `prefix + g` (also reachable through the `prefix o → g` table). The key is configurable with `@opensessions-popup-key` and the size with `@opensessions-popup-width` / `@opensessions-popup-height`. Unlike the docked sidebar, the TUI runs in popup mode (set through the OPENSESSIONS_POPUP env var) so it can be toggled freely: - skips IdentifyPane, so the server never resizes the transient pane to sidebar width or believes a docked sidebar is visible - quitting (q / Esc) or selecting an agent closes the popup locally and does not send /quit, keeping the shared server alive - agent-panel scope is kept local: it opens scoped to all sessions and the `a` toggle no longer rewrites the shared server scope - does not pull focus back to the launching pane on startup popup.sh cold-starts the server first via server-common.sh in case a prior popup or sidebar tore it down. Covered by unit tests for popup focus, local scope, local quit, and jump-to-pane-then-close behavior. --- README.md | 2 + apps/tui-rs/src/main.rs | 48 ++++++-- integrations/tmux-plugin/scripts/popup.sh | 21 ++++ opensessions.tmux | 8 ++ packages/sidebar-core-rs/src/app.rs | 138 ++++++++++++++++++++-- packages/sidebar-core-rs/src/input.rs | 8 +- 6 files changed, 206 insertions(+), 19 deletions(-) create mode 100755 integrations/tmux-plugin/scripts/popup.sh diff --git a/README.md b/README.md index d68a0fe..7af4f11 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ tmux source-file ~/.tmux.conf Open the sidebar with `prefix o → s`. +Pop up a floating agent-status panel with `prefix + g` (or `prefix o → g`): a centered overlay listing agents across all sessions, then `Enter` to jump to one. It opens scoped to all sessions; press `a` to toggle between all sessions and just the focused one. + TPM clones the repo into `~/.tmux/plugins/opensessions`. On first load, opensessions downloads the matching prebuilt release bundle into `bin/`; that bundle includes `opensessions-sidebar`, `opensessions-server`, and `lazydiff`. If your platform is unsupported or you are developing locally, you can still build from source: diff --git a/apps/tui-rs/src/main.rs b/apps/tui-rs/src/main.rs index 9ab2374..af7885e 100644 --- a/apps/tui-rs/src/main.rs +++ b/apps/tui-rs/src/main.rs @@ -84,6 +84,12 @@ async fn main() -> Result<()> { let identity = pane_identity_resolve(|key| std::env::var(key).ok(), tmux_display_message); + // Popup mode: launched inside a transient `tmux display-popup` (see + // integrations/tmux-plugin/scripts/popup.sh). + let popup_mode = std::env::var("OPENSESSIONS_POPUP") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); + debug_log(format!( "starting: connecting to ws://{server_host}:{server_port}/ identity={identity:?}" )); @@ -99,19 +105,25 @@ async fn main() -> Result<()> { let hello = decode_server_message(first.as_payload())?; validate_hello(&hello).map_err(anyhow::Error::msg)?; - if let Some(RuntimePaneIdentity { - pane_id, - session_name, - window_id, - }) = identity.clone() - { - let command = ClientCommand::IdentifyPane { + // Only docked sidebars identify their pane: the server treats IdentifyPane + // as a docked-sidebar connection and resizes the pane to sidebar width. A + // transient popup must skip it, or it would shrink to sidebar width and + // leave the server believing a docked sidebar is visible. + if !popup_mode { + if let Some(RuntimePaneIdentity { pane_id, session_name, window_id, - }; - ws.send(Message::text(encode_client_command(&command)?)) - .await?; + }) = identity.clone() + { + let command = ClientCommand::IdentifyPane { + pane_id, + session_name, + window_id, + }; + ws.send(Message::text(encode_client_command(&command)?)) + .await?; + } } let mut terminal = TerminalGuard::enter()?; @@ -250,6 +262,11 @@ async fn main() -> Result<()> { }); } } + if app.should_close { + // Popup close: FocusAgentPane etc. already sent above; + // exit without firing /quit so the server keeps running. + return Ok(()); + } for launch in app.drain_launches() { let target_session = launch .session_name() @@ -287,6 +304,9 @@ async fn main() -> Result<()> { for command in app.drain_commands() { send_or_queue_client_command(command, &mut ws, &mut pending_sidebar_width).await?; } + if app.should_close { + return Ok(()); + } for launch in app.drain_launches() { let target_session = launch .session_name() @@ -331,6 +351,9 @@ async fn main() -> Result<()> { identity.window_id, ); } + if popup_mode { + new_app.enter_popup_mode(); + } *slot = Some(new_app); } (Some(app), ServerMessage::State(state)) => { @@ -358,7 +381,10 @@ async fn main() -> Result<()> { } if !startup_refocused { startup_refocused = true; - if let Some(identity) = identity.as_ref() { + if popup_mode { + // A popup is its own transient client; don't pull + // focus back to the launching pane. + } else if let Some(identity) = identity.as_ref() { do_startup_refocus(&identity.pane_id); } } diff --git a/integrations/tmux-plugin/scripts/popup.sh b/integrations/tmux-plugin/scripts/popup.sh new file mode 100755 index 0000000..29efd32 --- /dev/null +++ b/integrations/tmux-plugin/scripts/popup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +# Open the agent-status panel as a centered floating popup via tmux display-popup. +# Unlike the docked sidebar, the TUI runs in popup mode (OPENSESSIONS_POPUP) and +# does not shut the shared server down on quit, so it can be toggled freely. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/server-common.sh" + +# Cold-start the server if a prior popup/sidebar quit tore it down. +ensure_server || exit 0 + +POPUP_WIDTH="$(tmux show-option -gqv '@opensessions-popup-width')" +POPUP_HEIGHT="$(tmux show-option -gqv '@opensessions-popup-height')" +POPUP_WIDTH="${POPUP_WIDTH:-80%}" +POPUP_HEIGHT="${POPUP_HEIGHT:-70%}" + +exec tmux display-popup \ + -w "$POPUP_WIDTH" \ + -h "$POPUP_HEIGHT" \ + -e "OPENSESSIONS_POPUP=1" \ + -E "\"${PLUGIN_DIR}\"/apps/tui/scripts/start.sh" diff --git a/opensessions.tmux b/opensessions.tmux index 6f96f50..0113bca 100755 --- a/opensessions.tmux +++ b/opensessions.tmux @@ -45,6 +45,7 @@ get_option() { } PREFIX_KEY=$(get_option "@opensessions-prefix-key" "o") +POPUP_KEY=$(get_option "@opensessions-popup-key" "g") FOCUS_GLOBAL_KEY=$(get_option "@opensessions-focus-global-key" "") INDEX_KEYS=$(get_option "@opensessions-index-keys" "") COMMAND_TABLE="opensessions" @@ -96,6 +97,9 @@ if [ -n "$PREFIX_KEY" ]; then tmux bind-key -T "$COMMAND_TABLE" s run-shell "sh '$SCRIPTS_DIR/focus.sh'" tmux bind-key -T "$COMMAND_TABLE" t run-shell "sh '$SCRIPTS_DIR/toggle.sh'" tmux bind-key -T "$COMMAND_TABLE" e run-shell "sh '$SCRIPTS_DIR/even-horizontal.sh' '#{window_id}' '#{pane_id}'" + if [ -n "$POPUP_KEY" ]; then + tmux bind-key -T "$COMMAND_TABLE" "$POPUP_KEY" run-shell "sh '$SCRIPTS_DIR/popup.sh'" + fi for i in 1 2 3 4 5 6 7 8 9; do tmux bind-key -T "$COMMAND_TABLE" "$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i" done @@ -106,6 +110,10 @@ fi # Both are safe to send as text from terminal emulators without timing issues. tmux bind-key C-s run-shell "sh '$SCRIPTS_DIR/focus.sh'" tmux bind-key C-t run-shell "sh '$SCRIPTS_DIR/toggle.sh'" +# Direct prefix binding for the floating agent-status popup (prefix + g). +if [ -n "$POPUP_KEY" ]; then + tmux bind-key "$POPUP_KEY" run-shell "sh '$SCRIPTS_DIR/popup.sh'" +fi for i in 1 2 3 4 5 6 7 8 9; do tmux bind-key "M-$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i" done diff --git a/packages/sidebar-core-rs/src/app.rs b/packages/sidebar-core-rs/src/app.rs index 2d92d8c..5344a8b 100644 --- a/packages/sidebar-core-rs/src/app.rs +++ b/packages/sidebar-core-rs/src/app.rs @@ -104,6 +104,10 @@ pub struct App { pub agent_panel_scope: AgentPanelScope, pub focused_agent_idx: usize, pub quit_deadline: Option, + /// Running inside a transient `tmux display-popup`: quitting closes the + /// popup locally without tearing down the shared server. + pub popup_mode: bool, + pub should_close: bool, pub flash_target: Option, pub flash_deadline: Option, pub hover_target: Option, @@ -150,6 +154,8 @@ impl App { agent_panel_scope: state.agent_panel_scope, focused_agent_idx: 0, quit_deadline: None, + popup_mode: false, + should_close: false, flash_target: None, flash_deadline: None, hover_target: None, @@ -176,6 +182,17 @@ impl App { app } + /// Open on the all-sessions agent list so finished/running agents are + /// visible at a glance. + pub fn enter_popup_mode(&mut self) { + self.popup_mode = true; + self.agent_panel_scope = AgentPanelScope::All; + if self.focused_agents_len() > 0 { + self.panel_focus = PanelFocus::Agents; + self.focused_agent_idx = 0; + } + } + pub fn set_terminal_width(&mut self, width: u16) { self.terminal_width = Some(width); } @@ -519,8 +536,12 @@ impl App { } } 'q' => { - self.commands.push(ClientCommand::Quit); - self.quit_deadline = Some(Instant::now() + Duration::from_millis(500)); + if self.popup_mode { + self.should_close = true; + } else { + self.commands.push(ClientCommand::Quit); + self.quit_deadline = Some(Instant::now() + Duration::from_millis(500)); + } } 'r' => self.commands.push(ClientCommand::Refresh), 'n' | 'c' => self.commands.push(ClientCommand::NewSession), @@ -660,10 +681,14 @@ impl App { AgentPanelScope::Current => AgentPanelScope::All, AgentPanelScope::All => AgentPanelScope::Current, }; - self.pending_agent_panel_scope_intent = Some(self.agent_panel_scope); - self.commands.push(ClientCommand::SetAgentPanelScope { - scope: self.agent_panel_scope, - }); + // The popup keeps its scope local, so skip the SetAgentPanelScope + // round-trip that would rewrite the shared server scope. + if !self.popup_mode { + self.pending_agent_panel_scope_intent = Some(self.agent_panel_scope); + self.commands.push(ClientCommand::SetAgentPanelScope { + scope: self.agent_panel_scope, + }); + } self.focused_agent_idx = self .focused_agent_idx .min(self.focused_agents_len().saturating_sub(1)); @@ -935,6 +960,22 @@ impl App { } fn apply_server_agent_panel_scope(&mut self, server_scope: AgentPanelScope) { + // The popup keeps a purely local scope (see `toggle_agent_panel_scope`), + // so ignore the server's value here; just follow focus onto agents once + // they appear and clamp the index if the list shrank. + if self.popup_mode { + if self.panel_focus != PanelFocus::Agents && self.focused_agents_len() > 0 { + self.panel_focus = PanelFocus::Agents; + self.focused_agent_idx = 0; + } + self.focused_agent_idx = self + .focused_agent_idx + .min(self.focused_agents_len().saturating_sub(1)); + if self.focused_agents_len() == 0 { + self.panel_focus = PanelFocus::Sessions; + } + return; + } if let Some(intent) = self.pending_agent_panel_scope_intent { if server_scope == intent { self.pending_agent_panel_scope_intent = None; @@ -974,7 +1015,10 @@ impl App { thread_name: target.thread_name, pane_id: target.pane_id, }); - } + // In popup mode, selecting an agent jumps to its pane and closes. + if self.popup_mode { + self.should_close = true; + } } pub fn dismiss_focused_agent(&mut self) { let agent_count = self.focused_agents_len(); @@ -1633,4 +1677,84 @@ mod tests { }] ); } + + fn state_with_one_agent() -> ServerState { + let mut state = empty_state(10); + let mut session = session("alpha", "/repo", false); + session.agents = vec![agent_without_pane("claude-code", AgentStatus::Done)]; + state.sessions = vec![session]; + state + } + + #[test] + fn enter_popup_mode_focuses_agents_panel_with_all_scope() { + let mut app = App::from_state(state_with_one_agent()); + app.enter_popup_mode(); + + assert!(app.popup_mode); + assert_eq!(app.agent_panel_scope, AgentPanelScope::All); + assert_eq!(app.panel_focus, PanelFocus::Agents); + } + + #[test] + fn popup_toggle_scope_is_local_and_survives_server_state_updates() { + let mut app = App::from_state(state_with_one_agent()); + app.enter_popup_mode(); + assert_eq!(app.agent_panel_scope, AgentPanelScope::All); + + app.toggle_agent_panel_scope(); + assert_eq!(app.agent_panel_scope, AgentPanelScope::Current); + assert!( + app.drain_commands() + .iter() + .all(|command| !matches!(command, ClientCommand::SetAgentPanelScope { .. })), + "popup scope toggle must not push SetAgentPanelScope" + ); + + let mut update = state_with_one_agent(); + update.agent_panel_scope = AgentPanelScope::All; + app.apply_server_message(ServerMessage::State(update)); + + assert_eq!(app.agent_panel_scope, AgentPanelScope::Current); + } + + #[test] + fn popup_mode_focuses_agents_when_state_update_populates_them() { + let mut app = App::from_state(empty_state(10)); + app.enter_popup_mode(); + assert_eq!(app.panel_focus, PanelFocus::Sessions); + + app.apply_server_message(ServerMessage::State(state_with_one_agent())); + + assert_eq!(app.panel_focus, PanelFocus::Agents); + assert_eq!(app.focused_agent_idx, 0); + assert_eq!(app.agent_panel_scope, AgentPanelScope::All); + } + + #[test] + fn popup_mode_quit_closes_locally_without_quit_command() { + let mut app = App::from_state(state_with_one_agent()); + app.enter_popup_mode(); + + app.handle_key_char('q'); + + assert!(app.should_close); + assert!(app.quit_deadline.is_none()); + assert!(app.drain_commands().is_empty()); + } + + #[test] + fn popup_mode_selecting_agent_focuses_pane_and_closes() { + let mut app = App::from_state(state_with_one_agent()); + app.enter_popup_mode(); + + app.activate_focused_item(); + + assert!(app.should_close); + assert!( + app.drain_commands() + .iter() + .any(|command| matches!(command, ClientCommand::FocusAgentPane { .. })) + ); + } } diff --git a/packages/sidebar-core-rs/src/input.rs b/packages/sidebar-core-rs/src/input.rs index 85774bc..ea55003 100644 --- a/packages/sidebar-core-rs/src/input.rs +++ b/packages/sidebar-core-rs/src/input.rs @@ -98,7 +98,13 @@ pub fn apply_ui_key(app: &mut App, key: UiKey) { } UiKey::Tab { shift } => app.handle_tab(shift), UiKey::Enter => app.activate_focused_item(), - UiKey::Esc => app.focus_sessions_panel(), + UiKey::Esc => { + if app.popup_mode { + app.should_close = true; + } else { + app.focus_sessions_panel(); + } + } UiKey::Backspace => {} UiKey::Char(ch) => app.handle_key_char(ch), } From 0c754d02b4ca095abeb8909f6de9fef929682798 Mon Sep 17 00:00:00 2001 From: TSHOGX Date: Sat, 27 Jun 2026 10:35:01 +0800 Subject: [PATCH 2/3] style(sidebar-core): fix brace placement in queue_focus_agent_pane The function's closing brace shared a line with the popup-mode if block, which rustfmt flagged. Put it on its own line. --- packages/sidebar-core-rs/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sidebar-core-rs/src/app.rs b/packages/sidebar-core-rs/src/app.rs index 5344a8b..9a750fa 100644 --- a/packages/sidebar-core-rs/src/app.rs +++ b/packages/sidebar-core-rs/src/app.rs @@ -1018,7 +1018,8 @@ impl App { // In popup mode, selecting an agent jumps to its pane and closes. if self.popup_mode { self.should_close = true; - } } + } + } pub fn dismiss_focused_agent(&mut self) { let agent_count = self.focused_agents_len(); From 44bcb7c6e0e94fc04b9ac4980f9740014e17e427 Mon Sep 17 00:00:00 2001 From: TSHOGX Date: Sat, 27 Jun 2026 10:36:23 +0800 Subject: [PATCH 3/3] refactor(tui): clarify popup startup-refocus guard Rewrite the empty popup_mode if-branch as an explicit `if !popup_mode` guard around the identity refocus. Behavior is unchanged (popups never refocus the launching pane; docked sidebars with an identity do), but the intent reads clearly without an empty branch that looked like a fall-through. --- apps/tui-rs/src/main.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/tui-rs/src/main.rs b/apps/tui-rs/src/main.rs index af7885e..442e77a 100644 --- a/apps/tui-rs/src/main.rs +++ b/apps/tui-rs/src/main.rs @@ -381,11 +381,13 @@ async fn main() -> Result<()> { } if !startup_refocused { startup_refocused = true; - if popup_mode { - // A popup is its own transient client; don't pull - // focus back to the launching pane. - } else if let Some(identity) = identity.as_ref() { - do_startup_refocus(&identity.pane_id); + // A popup is its own transient client, so skip the + // startup refocus that would pull focus back to the + // launching pane; only docked sidebars refocus. + if !popup_mode { + if let Some(identity) = identity.as_ref() { + do_startup_refocus(&identity.pane_id); + } } } }