Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 40 additions & 12 deletions apps/tui-rs/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
));
Expand All @@ -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()?;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)) => {
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions integrations/tmux-plugin/scripts/popup.sh
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions opensessions.tmux
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
137 changes: 131 additions & 6 deletions packages/sidebar-core-rs/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ pub struct App {
pub agent_panel_scope: AgentPanelScope,
pub focused_agent_idx: usize,
pub quit_deadline: Option<Instant>,
/// 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<HitTarget>,
pub flash_deadline: Option<Instant>,
pub hover_target: Option<HitTarget>,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 { .. }))
);
}
}
8 changes: 7 additions & 1 deletion packages/sidebar-core-rs/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down