From f0e9e1fca95406df1509703cfa6260a8fe0b1dbf Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Jul 2026 19:57:31 +0200 Subject: [PATCH] Highlight current selected pane in claude-mux --- claude-mux/README.md | 11 ++++- claude-mux/internal/tmux/tmux.go | 14 ++++++ claude-mux/internal/ui/list.go | 75 +++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/claude-mux/README.md b/claude-mux/README.md index e12c9af..f162296 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -55,6 +55,16 @@ Each session is shown on two lines (title, then status/meta) so it stays readabl in narrow popups, and the list and help text scroll/wrap to fit small windows. Sessions needing attention float to the top (waiting → running → open → closed). +The session you opened the picker from — the one running in the pane the popup is +floating over — is marked "you are here": its title is underlined and its meta +line carries a green `· this pane` tag. This is independent of the cursor, so you +can always see where you came from even after moving the selection or when the +list reorders. + +While the picker is open it reloads itself once a second, so statuses, titles and +message counts stay live automatically; the highlighted session is tracked +by id so the cursor stays on it even when the list reorders. + The live states (running / waiting / open) are reported by Claude Code hooks (`claude-mux hook --status …`), wired up in `modules/cli/home.nix`. @@ -65,7 +75,6 @@ The live states (running / waiting / open) are reported by Claude Code hooks | `n` | Start a fresh session | | `x` | Kill the selected running session | | `ctrl+a` | Toggle between this project and **all** projects | -| `r` | Refresh | | `q` / `esc` | Cancel | Selecting a session that is already running just jumps to its window; selecting an diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index f1b0efe..d7e9230 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -230,6 +230,20 @@ func (s *Server) RunningSessions() map[string]string { return result } +// CurrentSessionID returns the Claude session id tagged on the window the +// current client is focused on — i.e. the pane the picker popup was floated +// over. A popup runs on its own throwaway pane (so $TMUX_PANE is useless here) +// but a no-target display-message resolves against the client's active window, +// which is the underlying Claude window. Returns "" when it cannot be +// determined (not inside our server, or the window is untagged). +func (s *Server) CurrentSessionID() string { + out, err := s.run("display-message", "-p", "#{"+sessionIDOption+"}") + if err != nil { + return "" + } + return out +} + // TagWindow records sessionID on the window containing paneTarget (usually // $TMUX_PANE), so RunningSessions can find it later. func (s *Server) TagWindow(paneTarget, sessionID string) error { diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index 1a7e33c..a5c35bf 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -43,6 +43,9 @@ var ( selBar = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) selRowStyle = lipgloss.NewStyle().Bold(true) emptyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Italic(true) + // currentTag marks the row whose session is open in the pane the picker was + // floated over ("you are here"). + currentTag = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) // Per-status colours. runningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green @@ -83,6 +86,7 @@ type model struct { dir string srv *tmux.Server all bool + current string // session id open in the pane the picker was floated over entries []manager.Entry cursor int offset int // index of the first visible row (scrolling) @@ -96,6 +100,9 @@ type model struct { // When all is true it starts listing sessions across every project. func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { m := &model{dir: dir, all: all, srv: srv} + if srv.InsideOurServer() { + m.current = srv.CurrentSessionID() + } m.reload() p := tea.NewProgram(m, tea.WithAltScreen()) final, err := p.Run() @@ -106,9 +113,25 @@ func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { } func (m *model) reload() { + // Remember which session is highlighted so the cursor tracks it even when the + // list is reordered by an auto-reload (attention sorting moves rows around). + var selectedID string + if m.cursor >= 0 && m.cursor < len(m.entries) { + selectedID = m.entries[m.cursor].ID + } + entries, err := manager.Load(m.dir, m.all, m.srv) m.err = err m.entries = entries + + if selectedID != "" { + for i, e := range entries { + if e.ID == selectedID { + m.cursor = i + break + } + } + } if m.cursor >= len(entries) { m.cursor = len(entries) - 1 } @@ -117,7 +140,18 @@ func (m *model) reload() { } } -func (m *model) Init() tea.Cmd { return nil } +// reloadInterval is how often an open picker re-reads sessions so its statuses, +// titles and message counts stay live without the user pressing "r". +const reloadInterval = time.Second + +// tickMsg is delivered on every reload tick. +type tickMsg struct{} + +func tick() tea.Cmd { + return tea.Tick(reloadInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func (m *model) Init() tea.Cmd { return tick() } // linesPerEntry is the height of one rendered session (two-line layout). const linesPerEntry = 2 @@ -174,6 +208,10 @@ func (m *model) clampScroll() { func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tickMsg: + m.reload() + m.clampScroll() + return m, tick() case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.clampScroll() @@ -194,8 +232,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 case "G", "end": m.cursor = len(m.entries) - 1 - case "r": - m.reload() case "ctrl+a": m.all = !m.all m.cursor = 0 @@ -302,8 +338,11 @@ func (m *model) positionText() string { // below. The two-line layout keeps everything readable in narrow popups. func (m *model) renderRow(i int, e manager.Entry, width int) string { selected := i == m.cursor + isCurrent := e.ID != "" && e.ID == m.current - // Line 1: selection bar + status dot + title. + // Line 1: selection bar + status dot + title. The current session (the one + // open in the pane the picker was floated over) is underlined — "you are + // here" — independently of the cursor. bar := " " if selected { bar = selBar.Render("▌ ") @@ -314,11 +353,14 @@ func (m *model) renderRow(i int, e manager.Entry, width int) string { titleW = 4 } title := truncate(e.Title, titleW) + ts := titleStyle if selected { - title = selRowStyle.Render(title) - } else { - title = titleStyle.Render(title) + ts = selRowStyle } + if isCurrent { + ts = ts.Underline(true) + } + title = ts.Render(title) line1 := bar + dot + " " + title // Line 2: indented status/meta, continuing the selection bar. @@ -331,8 +373,21 @@ func (m *model) renderRow(i int, e manager.Entry, width int) string { parts = append(parts, filepath.Base(e.ProjectDir)) } parts = append(parts, fmt.Sprintf("%d msg", e.Messages), relTime(e.Updated)) - meta := truncate(strings.Join(parts, " · "), width-4) - line2 := indent + metaStyle.Render(meta) + + // A "this pane" tag flags the current session; reserve room for it so the + // meta text truncates around it rather than overflowing the row. + const tag = "this pane" + metaW := width - 4 + if isCurrent { + if metaW -= len(tag) + 3; metaW < 4 { // 3 = " · " + metaW = 4 + } + } + meta := metaStyle.Render(truncate(strings.Join(parts, " · "), metaW)) + if isCurrent { + meta += metaStyle.Render(" · ") + currentTag.Render(tag) + } + line2 := indent + meta return line1 + "\n" + line2 } @@ -342,7 +397,7 @@ func (m *model) helpText() string { if m.all { scope = "ctrl+a this project" } - return "↑/↓ move · enter open · n new · x kill · " + scope + " · r refresh · q cancel" + return "↑/↓ move · enter open · n new · x kill · " + scope + " · q cancel" } // pad right-pads s to at least n runes.