Files
flake/claude-mux/internal/ui/list.go
T

442 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package ui renders the floating session picker shown by "claude-mux list".
package ui
import (
"fmt"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"claude-mux/internal/manager"
"claude-mux/internal/session"
"claude-mux/internal/tmux"
)
// Action is what the user chose to do when the picker closed.
type Action int
const (
// ActionNone means the picker was cancelled.
ActionNone Action = iota
// ActionResume means open the selected session (Result.Entry).
ActionResume
// ActionNew means start a fresh session.
ActionNew
)
// Result is the outcome of running the picker.
type Result struct {
Action Action
Entry manager.Entry
}
var (
titleStyle = lipgloss.NewStyle().Bold(true)
dirStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
projStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
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
questionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // amber
openStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue
closedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // dim
)
// statusDot returns a coloured glyph for a session status.
func statusDot(s session.Status) string {
switch s {
case session.StatusRunning:
return runningStyle.Render("●")
case session.StatusQuestions:
return questionStyle.Render("⬤")
case session.StatusIdle:
return openStyle.Render("⬤")
default: // closed
return closedStyle.Render("◯")
}
}
// statusWord returns the human label for a session status.
func statusWord(s session.Status) string {
switch s {
case session.StatusRunning:
return "running"
case session.StatusQuestions:
return "waiting for you"
case session.StatusIdle:
return "open"
default:
return "closed"
}
}
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)
width int
height int
err error
result Result
}
// RunPicker shows the interactive picker for dir and returns the chosen action.
// 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()
if err != nil {
return Result{}, err
}
return final.(*model).result, nil
}
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
}
if m.cursor < 0 {
m.cursor = 0
}
}
// 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
func (m *model) dims() (width, height int) {
width, height = m.width, m.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
return
}
// footerHeight is how many lines the (wrapped) help text plus the position line
// occupy at the current width.
func (m *model) footerHeight() int {
width, _ := m.dims()
return lipgloss.Height(helpStyle.Width(width).Render(m.helpText())) + 1
}
// pageSize is how many session entries fit, given the two-line layout and the
// space taken by the header (1 line) and footer.
func (m *model) pageSize() int {
_, height := m.dims()
body := height - 1 - m.footerHeight()
if body < linesPerEntry {
return 1
}
return body / linesPerEntry
}
// clampScroll keeps the cursor within the visible window and the offset in range.
func (m *model) clampScroll() {
page := m.pageSize()
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+page {
m.offset = m.cursor - page + 1
}
maxOff := len(m.entries) - page
if maxOff < 0 {
maxOff = 0
}
if m.offset > maxOff {
m.offset = maxOff
}
if m.offset < 0 {
m.offset = 0
}
}
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()
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
m.result = Result{Action: ActionNone}
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.entries)-1 {
m.cursor++
}
case "g", "home":
m.cursor = 0
case "G", "end":
m.cursor = len(m.entries) - 1
case "ctrl+a":
m.all = !m.all
m.cursor = 0
m.reload()
case "n":
m.result = Result{Action: ActionNew}
return m, tea.Quit
case "x":
// Kill the selected session's running window. Idle sessions have no
// live process, so there is nothing to kill.
if len(m.entries) > 0 {
if e := m.entries[m.cursor]; e.Target != "" {
_ = m.srv.KillWindow(e.Target)
m.reload()
}
}
case "enter":
if len(m.entries) > 0 {
m.result = Result{Action: ActionResume, Entry: m.entries[m.cursor]}
return m, tea.Quit
}
}
m.clampScroll()
}
return m, nil
}
func (m *model) View() string {
width, _ := m.dims()
var b strings.Builder
b.WriteString(m.renderHeader(width))
b.WriteString("\n")
if m.err != nil {
b.WriteString(emptyStyle.Width(width).Render("error: " + m.err.Error()))
return b.String()
}
if len(m.entries) == 0 {
msg := "No sessions yet for this project."
if m.all {
msg = "No sessions found."
}
b.WriteString(emptyStyle.Width(width).Render(msg))
b.WriteString("\n")
b.WriteString(m.renderFooter(width))
return b.String()
}
page := m.pageSize()
end := m.offset + page
if end > len(m.entries) {
end = len(m.entries)
}
for i := m.offset; i < end; i++ {
b.WriteString(m.renderRow(i, m.entries[i], width))
b.WriteString("\n")
}
b.WriteString(m.renderFooter(width))
return b.String()
}
// renderHeader is the single (truncated) title line.
func (m *model) renderHeader(width int) string {
scope := m.dir
if m.all {
scope = "all projects"
}
const prefix = "Claude sessions · "
avail := width - len([]rune(prefix))
if avail < 4 {
return titleStyle.Render(truncate("Claude sessions", width))
}
return titleStyle.Render("Claude sessions") + dimStyle.Render(" · ") + dirStyle.Render(truncate(scope, avail))
}
// renderFooter renders the wrapped help text plus a position/summary line.
func (m *model) renderFooter(width int) string {
help := helpStyle.Width(width).Render(m.helpText())
return help + "\n" + dimStyle.Render(m.positionText())
}
// positionText summarises how much of the list is shown.
func (m *model) positionText() string {
total := len(m.entries)
if total == 0 {
return ""
}
page := m.pageSize()
if total > page {
end := m.offset + page
if end > total {
end = total
}
return fmt.Sprintf("%d–%d of %d", m.offset+1, end, total)
}
if total == 1 {
return "1 session"
}
return fmt.Sprintf("%d sessions", total)
}
// renderRow renders one session across two lines: title on top, status/meta
// 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. 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("▌ ")
}
dot := statusDot(e.Status)
titleW := width - 4 // bar(2) + dot(1) + space(1)
if titleW < 4 {
titleW = 4
}
title := truncate(e.Title, titleW)
ts := titleStyle
if selected {
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.
indent := " "
if selected {
indent = selBar.Render("▌ ") + " "
}
parts := []string{statusWord(e.Status)}
if m.all {
parts = append(parts, filepath.Base(e.ProjectDir))
}
parts = append(parts, fmt.Sprintf("%d msg", e.Messages), relTime(e.Updated))
// 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
}
func (m *model) helpText() string {
scope := "ctrl+a all"
if m.all {
scope = "ctrl+a this project"
}
return "↑/↓ move · enter open · n new · x kill · " + scope + " · q cancel"
}
// pad right-pads s to at least n runes.
func pad(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
// truncate shortens s to at most n runes, adding an ellipsis when cut.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}
// relTime renders a compact "time ago" string, or a date for older sessions.
func relTime(t time.Time) string {
if t.IsZero() {
return "—"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
default:
return t.Format("Jan 2")
}
}