"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
"github.com/samber/lo"
"golang.org/x/term"
"tobi/internal/remote"
pickStyle = lipgloss.NewStyle().Background(surface)
borderStyle = lipgloss.NewStyle().Foreground(overlay)
)
const maxUIWidth = 76
const (
maxUIWidth = 76
startWidth = 80 // assumed until the first WindowSizeMsg lands
)
type hostRow struct {
name string
local bool
lookDir int // -1 left, +1 right; persists after the blink
holdTicks int // how long the gaze-hold lasts after a blink
probes chan probeResult
pending int
selected *session.Session
picked *pickEntry
}
type pickEntry struct {
host string // ssh destination of the host the line belongs to
sess *session.Session // nil when the line is the host row itself
}
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 } }
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.filter.Width = min(msg.Width, maxUIWidth) - 8
m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width
case probeResult:
r := &m.rows[msg.row]
r.probing = false
r.duration = msg.duration
byNewest(msg.sessions)
r.sessions = msg.sessions
m.pending--
m.clamp()
if m.pending > 0 {
return m, waitForProbe(m.probes)
}
return m, nil
case tea.KeyUp:
m.cursor = max(0, m.cursor-1)
return m, nil
case tea.KeyDown:
m.cursor++
m.cursor = min(len(m.entries())-1, m.cursor+1)
return m, nil
case tea.KeyEnter:
if v := m.visible(); m.cursor < len(v) {
m.selected = &v[m.cursor]
if e := m.entries(); m.cursor < len(e) {
m.picked = &e[m.cursor]
return m, tea.Quit
}
return m, nil
case tea.KeyEsc:
}
return m, nil
}
func (m pickerModel) visible() []session.Session {
return rankSessions(lo.FlatMap(m.rows, func(r hostRow, _ int) []session.Session { return r.sessions }), m.filter.Value(), false)
func (m pickerModel) rowsFor() []hostRow {
q := strings.ToLower(strings.TrimSpace(m.filter.Value()))
if q == "" {
return m.rows
}
var out []hostRow
for _, r := range m.rows {
if len(fuzzy.Find(q, []string{strings.ToLower(r.name)})) > 0 {
out = append(out, r)
continue
}
if hits := rankSessions(r.sessions, q, false); len(hits) > 0 {
r.sessions = hits
out = append(out, r)
}
}
return out
}
// in draw order, so the cursor and the table cannot disagree; hosts we cannot
// reach are drawn but not selectable
func (m pickerModel) entries() []pickEntry {
var out []pickEntry
for _, r := range m.rowsFor() {
if selectableHost(r) {
out = append(out, pickEntry{host: r.name})
}
for _, s := range r.sessions {
out = append(out, pickEntry{host: r.name, sess: &s})
}
}
return out
}
func selectableHost(r hostRow) bool { return !r.peerOffline && !r.unreachable }
func (m *pickerModel) clamp() { m.cursor = max(0, min(len(m.entries())-1, m.cursor)) }
var kittyFace = [...]string{"(^% % ^)", "(^ % % ^)", "(^ % %^)"}
const eyeDozing, eyeOpen, eyeLooking = "-", "•", "."
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 != "" {
for _, s := range m.visible() {
sessionRow(&b, w, cursor, m.cursor, s, s.Canonical(), " ", "› ")
cursor++
}
} else {
cursor = m.drawHostTable(&b, w, cursor)
rows := m.rowsFor()
if len(rows) == 0 {
b.WriteString(" " + faintStyle.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n")
}
m.drawHostTable(&b, w, rows)
b.WriteString(borderStyle.Render(strings.Repeat("─", w)) + "\n")
hint := func(k, act string) string {
return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act)
}
hs := m.hints()
total := 0
for _, h := range pickerHints {
for _, h := range hs {
total += h.w
}
hs := pickerHints
for len(hs) > 1 && total+3*(len(hs)-1) > w {
total -= hs[len(hs)-1].w
hs = hs[:len(hs)-1]
}
next = w - h.w
}
b.WriteString(strings.Repeat(" ", max(0, next-x)))
b.WriteString(hint(h.k, h.act))
x = next + h.w
x = max(next, x) + h.w // a hint that overran its column pushes the rest right
}
return b.String()
}
var pickerHints = []struct {
type pickerHint struct {
k, act string
w int
}{
{"enter", "attach", 14},
{"↑↓", "move", 9},
{"esc", "clear", 11},
{"ctrl+c", "quit", 13},
}
func (m pickerModel) hints() []pickerHint {
act := "attach"
if e := m.entries(); m.cursor < len(e) && e[m.cursor].sess == nil {
act = "new shell"
}
hs := []pickerHint{{k: "enter", act: act}, {k: "↑↓", act: "move"}, {k: "esc", act: "clear"}, {k: "ctrl+c", act: "quit"}}
for i := range hs {
hs[i].w = utf8.RuneCountInString(hs[i].k) + utf8.RuneCountInString(hs[i].act) + 3
}
return hs
}
// the wrapper we start bare hosts with is plumbing, not something worth
// reading in the command column
func commandLabel(s session.Session) string {
return lo.Ternary(s.Command == strings.Join(loginShell, " "), "shell", s.Command)
}
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
age, cmd := relTime(s.StartedAt), commandLabel(s)
rest := label + " " + cmd + " " + s.Cwd
if cursor == sel {
b.WriteString(pickStyle.Render(selPad+rest+strings.Repeat(" ", max(0, w-1-utf8.RuneCountInString(age)-utf8.RuneCountInString(selPad+rest)))+" "+age) + "\n")
return
}
left := pad + mauveStyle.Render(label) + " " + lavenderStyle.Render(s.Command) + " " + yellowStyle.Render(s.Cwd)
left := pad + mauveStyle.Render(label) + " " + lavenderStyle.Render(cmd) + " " + yellowStyle.Render(s.Cwd)
fill := max(0, w-utf8.RuneCountInString(age)-lipgloss.Width(left))
b.WriteString(left + strings.Repeat(" ", fill) + age + "\n")
}
func (m pickerModel) drawHostTable(b *strings.Builder, w, cursor int) int {
func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) {
nameW := 0
for _, r := range m.rows {
for _, r := range rows {
nameW = max(nameW, utf8.RuneCountInString(r.name))
}
nameW = min(nameW, 22)
cursor := 0
for _, r := range m.rows {
for _, r := range rows {
glyph, sess := "○", "no sessions"
glyphSt, sessSt, nameSt := dimStyle, dimStyle, textStyle
ping := dimStyle.Render(fmt.Sprintf("%.1fs", r.duration.Seconds()))
pingSt := dimStyle
ping := 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()))
ping = 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()))
pingSt = salmonStyle.Faint(true)
case r.peerOffline:
glyph, sess, ping = "×", "offline", ""
glyphSt, sessSt, nameSt = faintStyle, faintStyle, faintStyle
case len(r.sessions) > 0:
if r.local {
reserve += 8 // the local pill and its gap
}
sessW := min(12, max(0, w-3-2-nameW-3-reserve))
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")
selected := selectableHost(r) && cursor == m.cursor
if selectableHost(r) {
cursor++
}
// the highlighted row is rendered unstyled inside one background run,
// so nested colour resets cannot punch holes in it
if selected {
plain := lipgloss.NewStyle()
left := " › " + plain.Width(nameW).Render(r.name) + " " + plain.Width(12).MaxWidth(sessW).Render(sess)
right := lo.Ternary(r.local, " local ", ping)
fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right))
b.WriteString(pickStyle.Render(left+strings.Repeat(" ", fill)+right) + "\n")
} else {
left := " " + glyphSt.Width(2).Render(glyph) + nameSt.Width(nameW).Render(r.name) + " " + sessSt.Width(12).MaxWidth(sessW).Render(sess)
right := pingSt.Render(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")
fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right))
b.WriteString(left + strings.Repeat(" ", fill) + right + "\n")
}
for _, s := range r.sessions {
sessionRow(b, w, cursor, m.cursor, s, s.ID, " ", " › ")
cursor++
}
}
return cursor
}
func runPicker(local []session.Session, initial string) error {
peersCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
filter.Prompt = "› "
filter.Placeholder = "search sessions…"
filter.PlaceholderStyle = faintStyle
filter.Focus()
filter.Width = startWidth - 8 // before the value, so a long initial query scrolls instead of overflowing
if initial = strings.TrimSpace(initial); initial != "" {
filter.SetValue(initial)
}
m := pickerModel{
filter: filter,
width: 80,
width: startWidth,
probes: probes,
spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
rows: []hostRow{{name: self, local: true, sessions: local}},
}
m.filter.Width = m.width - 8
m.lookDir, m.nextBlink = []int{-1, 1}[rand.IntN(2)], 30+rand.IntN(31)
online := lo.Filter(peers, func(p remote.Peer, _ int) bool { return p.Online && !strings.EqualFold(p.Name, self) })
var offline []remote.Peer
if os.Getenv("TOBI_OFFLINE") == "1" {
probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(m.rows[row].started))}
}(i, m.rows[i].name)
}
m.clamp()
model, err := tea.NewProgram(m).Run()
if err != nil {
return err
}
if final := model.(pickerModel); final.selected != nil {
return connectOrHop(*final.selected)
picked := model.(pickerModel).picked
switch {
case picked == nil:
return nil
case picked.sess != nil:
return connectOrHop(*picked.sess)
}
return nil
fmt.Fprintln(os.Stderr, dimStyle.Render("starting a shell on "+picked.host+"…"))
return Spawn(picked.host, loginShell)
}
// ask the target for its own login shell rather than carrying ours across,
// since the path we run under need not exist on the other host
var loginShell = []string{"sh", "-lc", `exec "${SHELL:-/bin/sh}"`}
func relTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute: