Highlight current selected pane in claude-mux

This commit is contained in:
2026-07-26 21:27:57 +02:00
parent f7088d9f81
commit f0e9e1fca9
3 changed files with 89 additions and 11 deletions
+10 -1
View File
@@ -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
+14
View File
@@ -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 {
+65 -10
View File
@@ -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.