duration time.Duration
sessions []session.Session
}
// probeResult carries the row index it was scheduled for, so Update can
// patch m.rows directly instead of re-finding the host by name
type probeResult struct {
host string
row int
sessions []session.Session
err error
duration time.Duration
}
pending int
selected *session.Session
}
func (m pickerModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, waitForProbe(m.probes))
}
func (m pickerModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, waitForProbe(m.probes)) }
func waitForProbe(probes chan probeResult) tea.Cmd {
return func() tea.Msg { return <-probes }
}
func waitForProbe(probes chan probeResult) tea.Cmd { return func() tea.Msg { return <-probes } }
func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.filter.Width = min(msg.Width, maxUIWidth) - 8
case probeResult:
for i := range m.rows {
if m.rows[i].name == msg.host {
m.rows[i].probing = false
m.rows[i].unreachable = errors.Is(msg.err, remote.ErrUnreachable)
m.rows[i].duration = msg.duration
byNewest(msg.sessions)
m.rows[i].sessions = msg.sessions
r := &m.rows[msg.row]
r.probing = false
r.unreachable = errors.Is(msg.err, remote.ErrUnreachable)
r.duration = msg.duration
byNewest(msg.sessions)
r.sessions = msg.sessions
}
}
m.pending--
if m.pending > 0 {
return m, waitForProbe(m.probes)
}
m.cursor++
return m, nil
case tea.KeyEnter:
if v := m.visible(); m.cursor < len(v) {
s := v[m.cursor]
m.selected = &s
m.selected = &v[m.cursor]
return m, tea.Quit
}
return m, nil
case tea.KeyEsc:
return m, nil
}
func (m pickerModel) visible() []session.Session {
var out []session.Session
for _, r := range m.rows {
out = append(out, r.sessions...)
}
return rankSessions(out, m.filter.Value())
return rankSessions(lo.FlatMap(m.rows, func(r hostRow, _ int) []session.Session { return r.sessions }), m.filter.Value())
}
func (m pickerModel) total() int {
return lo.SumBy(m.rows, func(r hostRow) int { return len(r.sessions) })
}
// kittyFace is indexed by gaze direction+1: left, center, right; % marks
// the eye character
var kittyFace = [...]string{"(^% % ^)", "(^ % % ^)", "(^ % %^)"}
const (
kittyRight = "(^ % %^)"
kittyCenter = "(^ % % ^)"
kittyLeft = "(^% % ^)"
eyeDozing = "-"
eyeOpen = "•"
eyeLooking = "."
eyeDozing = "-"
eyeOpen = "•"
eyeLooking = "."
)
func makeKitty(dir int, char string) string {
var tmpl string
switch {
case dir >= 1:
tmpl = kittyRight
case dir == 0:
tmpl = kittyCenter
case dir <= -1:
tmpl = kittyLeft
}
return strings.ReplaceAll(tmpl, "%", char)
return strings.ReplaceAll(kittyFace[min(2, max(0, dir+1))], "%", char)
}
func (m pickerModel) View() string {
w := min(m.width, maxUIWidth)
// header box: the kitty and name sit on the top border, counters dock
// right; the filter field lives inside the box
counts := fmt.Sprintf("%d host%s · %d active ", len(m.rows), plural(len(m.rows)), m.total())
kitty := makeKitty(0, eyeDozing)
switch {
case m.filter.Value() == "":
kitty := makeKitty(m.lookDir, eyeDozing)
if m.filter.Value() != "" {
// pupils follow the cursor's column within the input field
kitty = makeKitty(m.filter.Position()/10-1, eyeLooking)
} else if m.blinking {
// dozing; after a random 3-6s the eyes open toward the chosen
// direction, pause, blink close-open-close, then hold that gaze
kitty = makeKitty(m.lookDir, eyeDozing)
if m.blinking {
t := m.twitch - m.blinkAt
switch {
case t < 10 || 12 <= t && t < 14: // eyes open, straight
kitty = makeKitty(m.lookDir, eyeOpen)
if t := m.twitch - m.blinkAt; t < 10 || t >= 12 && t < 14 { // eyes open, straight
kitty = makeKitty(m.lookDir, eyeOpen)
}
}
default:
// pupils follow the cursor's column within the input field
pos := m.filter.Position()
lookDir := (pos / 10) - 1
kitty = makeKitty(lookDir, eyeLooking)
}
title := mauveStyle.Render(kitty+" ") + textStyle.Bold(true).Render("tobi ")
wt := utf8.RuneCountInString(kitty) + 6
var top string
mid := strings.Repeat("─", max(1, w-5-wt))
if wt+utf8.RuneCountInString(counts) <= w-6 {
top = "╭─ " + title + borderStyle.Render(strings.Repeat("─", w-6-wt-utf8.RuneCountInString(counts))+" "+counts+"─╮")
} else {
top = "╭─ " + title + borderStyle.Render(strings.Repeat("─", max(1, w-5-wt))+"─╮")
mid = strings.Repeat("─", w-6-wt-utf8.RuneCountInString(counts)) + " " + counts
}
b.WriteString(top + "\n")
b.WriteString("╭─ " + title + borderStyle.Render(mid+"─╮") + "\n")
filter := m.filter.View()
b.WriteString(borderStyle.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + borderStyle.Render(" │") + "\n")
b.WriteString(borderStyle.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n")
cursor := 0
if q := strings.TrimSpace(m.filter.Value()); q != "" {
// search mode: one flat list across every host, best match first
for _, s := range m.visible() {
age := relTime(s.StartedAt)
rest := s.Canonical() + " " + s.Command + " " + s.Cwd
if cursor == m.cursor {
b.WriteString(pickStyle.Render(padTo("› "+rest, w-1-utf8.RuneCountInString(age))+" "+age) + "\n")
} else {
left2 := " " + mauveStyle.Render(s.Canonical()) + " " + lavenderStyle.Render(s.Command) + " " + yellowStyle.Render(s.Cwd)
fill2 := max(0, w-utf8.RuneCountInString(age)-lipgloss.Width(left2))
b.WriteString(left2 + strings.Repeat(" ", fill2) + age + "\n")
}
sessionRow(&b, w, cursor, m.cursor, s, s.Canonical(), " ", "› ")
cursor++
}
} else {
cursor = m.drawHostTable(&b, w, cursor)
b.WriteString(borderStyle.Render(strings.Repeat("─", w)) + "\n")
hint := func(k, act string) string {
return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act)
}
type hk struct{ k, act string }
hs := []hk{{"enter", "attach"}, {"↑↓", "move"}, {"esc", "clear"}, {"ctrl+c", "quit"}}
widths := make([]int, len(hs))
total := 0
for i, h := range hs {
widths[i] = utf8.RuneCountInString(h.k) + utf8.RuneCountInString(h.act) + 3
total += widths[i]
for _, h := range pickerHints {
total += h.w
}
hs := pickerHints
for len(hs) > 1 && total+3*(len(hs)-1) > w {
total -= widths[len(hs)-1]
total -= hs[len(hs)-1].w
hs = hs[:len(hs)-1]
}
col := max(1, (w-widths[len(hs)-1])/(len(hs)-1))
col := max(1, (w-hs[len(hs)-1].w)/(len(hs)-1))
x := 0
for i, h := range hs {
next := i * col
if i == len(hs)-1 {
next = w - widths[i]
next = w - h.w
}
b.WriteString(strings.Repeat(" ", max(0, next-x)))
b.WriteString(hint(h.k, h.act))
x = next + widths[i]
x = next + h.w
}
return b.String()
}
// footer hints are immutable; w is the plain rune width of "[k] act"
var pickerHints = []struct {
k, act string
w int
}{
{"enter", "attach", 14},
{"↑↓", "move", 9},
{"esc", "clear", 11},
{"ctrl+c", "quit", 13},
}
// sessionRow renders one selectable session line, shared by search mode
// (canonical label) and grouped mode (id label); pad and selPad are the
// unselected and selected leading indents
func sessionRow(b *strings.Builder, w, cursor, sel int, s session.Session, label, pad, selPad string) {
age := relTime(s.StartedAt)
rest := label + " " + s.Command + " " + s.Cwd
if cursor == sel {
// one style for the whole highlighted row, no nested resets
b.WriteString(pickStyle.Render(padTo(selPad+rest, w-1-utf8.RuneCountInString(age))+" "+age) + "\n")
return
}
left := pad + mauveStyle.Render(label) + " " + lavenderStyle.Render(s.Command) + " " + yellowStyle.Render(s.Cwd)
fill := max(0, w-utf8.RuneCountInString(age)-lipgloss.Width(left))
b.WriteString(left + strings.Repeat(" ", fill) + age + "\n")
}
// drawHostTable renders the host table and its session sub-rows; cursor
// counts selectable sessions and is returned so View can resume counting
func (m pickerModel) drawHostTable(b *strings.Builder, w, cursor int) int {
nameW := 0
nameW = max(nameW, utf8.RuneCountInString(r.name))
}
nameW = min(nameW, 22)
for _, r := range m.rows {
// one state derivation per row: glyph, status text, ping cell and
// the three cell styles all switch on the same state
glyph, sess := "○", "no sessions"
glyphSt, sessSt, nameSt := dimStyle, dimStyle, textStyle
ping := dimStyle.Render(fmt.Sprintf("%.1fs", r.duration.Seconds()))
switch {
case r.probing:
sess = "searching"
ping = dimStyle.Render(fmt.Sprintf("%s %.1fs", m.spinner.View(), probeClock(time.Since(r.started)).Seconds()))
case r.unreachable:
glyph, sess = "!", "unreachable"
glyphSt, sessSt, nameSt = salmonStyle, salmonStyle.Faint(true), faintStyle
ping = salmonStyle.Faint(true).Render(fmt.Sprintf("%.1fs", r.duration.Seconds()))
case r.peerOffline:
glyph, sess = "×", "offline"
glyph, sess, ping = "×", "offline", ""
glyphSt, sessSt, nameSt = faintStyle, faintStyle, faintStyle
case len(r.sessions) > 0:
glyph = "●"
sess = fmt.Sprintf("%d session%s", len(r.sessions), plural(len(r.sessions)))
glyph, sess = "●", fmt.Sprintf("%d session%s", len(r.sessions), plural(len(r.sessions)))
glyphSt, sessSt = mintStyle, mintStyle.Faint(true)
}
var pingStyled string
switch {
case r.probing:
pingStyled = dimStyle.Render(fmt.Sprintf("%s %.1fs", m.spinner.View(), probeClock(time.Since(r.started)).Seconds()))
case r.unreachable:
pingStyled = salmonStyle.Faint(true).Render(fmt.Sprintf("%.1fs", r.duration.Seconds()))
case r.local || r.peerOffline:
default:
pingStyled = dimStyle.Render(fmt.Sprintf("%.1fs", r.duration.Seconds()))
}
glyphStyle, sessSt := dimStyle, dimStyle
switch {
case r.unreachable:
glyphStyle, sessSt = salmonStyle, salmonStyle.Faint(true)
case r.peerOffline:
glyphStyle, sessSt = faintStyle, faintStyle
case len(r.sessions) > 0:
glyphStyle, sessSt = mintStyle, mintStyle.Faint(true)
}
name := lipgloss.NewStyle().Foreground(textC).Width(nameW).Render(r.name)
if r.peerOffline || r.unreachable {
name = faintStyle.Width(nameW).Render(r.name)
}
// the status cell yields space on very narrow terminals
reserve := 5
if r.local {
reserve += 8 // the local pill and its gap
}
sessW := min(12, max(0, w-3-2-nameW-3-reserve))
left := " " + glyphStyle.Width(2).Render(glyph) + name + " " + sessSt.Width(12).MaxWidth(sessW).Render(sess)
right := pingStyled
left := " " + glyphSt.Width(2).Render(glyph) + nameSt.Width(nameW).Render(r.name) + " " + sessSt.Width(12).MaxWidth(sessW).Render(sess)
right := ping
if r.local {
right = selfPill.Render("local")
}
fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right))
b.WriteString(left + strings.Repeat(" ", fill) + right + "\n")
for _, s := range r.sessions {
age := relTime(s.StartedAt)
rest := s.ID + " " + s.Command + " " + s.Cwd
if cursor == m.cursor {
// one style for the whole highlighted row, no nested resets
b.WriteString(pickStyle.Render(padTo(" › "+rest, w-1-utf8.RuneCountInString(age))+" "+age) + "\n")
} else {
left2 := " " + mauveStyle.Render(s.ID) + " " + lavenderStyle.Render(s.Command) + " " + yellowStyle.Render(s.Cwd)
fill2 := max(0, w-utf8.RuneCountInString(age)-lipgloss.Width(left2))
b.WriteString(left2 + strings.Repeat(" ", fill2) + age + "\n")
}
sessionRow(b, w, cursor, m.cursor, s, s.ID, " ", " › ")
cursor++
}
}
}
for _, p := range offline {
m.rows = append(m.rows, hostRow{name: p.Name, peerOffline: true})
}
m.pending = lo.CountBy(m.rows, func(r hostRow) bool { return r.probing })
// rows are final now: probes identify themselves by row index, never by
// a pointer that a later append could invalidate
for i := range m.rows {
if !m.rows[i].probing {
continue
}
m.pending++
m.rows[i].started = time.Now()
go func(row *hostRow) {
go func(row int, name string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
list, err := remote.QueryRemote(ctx, row.name)
probes <- probeResult{host: row.name, sessions: list, err: err, duration: probeClock(time.Since(row.started))}
}(&m.rows[i])
list, err := remote.QueryRemote(ctx, name)
probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(m.rows[row].started))}
}(i, m.rows[i].name)
}
model, err := tea.NewProgram(m).Run()
if err != nil {