diff --git a/README.md b/README.md index d68a0fed..7af4f11e 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 9ab23742..442e77a1 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,8 +381,13 @@ async fn main() -> Result<()> { } if !startup_refocused { startup_refocused = true; - 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); + } } } } diff --git a/integrations/tmux-plugin/scripts/popup.sh b/integrations/tmux-plugin/scripts/popup.sh new file mode 100755 index 00000000..29efd32f --- /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 6f96f507..0113bca4 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 2d92d8c3..9a750fa6 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,6 +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) { @@ -1633,4 +1678,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 85774bcb..ea55003f 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), }