package app
import (
"context"
"errors"
"fmt"
"image/color"
"math/rand/v2"
"os"
"slices"
"strings"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/sahilm/fuzzy"
"github.com/samber/lo"
"golang.org/x/term"
"tobi/internal/remote"
"tobi/internal/session"
)
const (
maxUIWidth = 76
startWidth = 80
)
// the picker's palette: catppuccin latte for light terminals, frappé for dark
// ones. it starts dark and is rebuilt once the terminal answers the
// background query the program issues on startup.
type theme struct {
text, dim, faint, mint, yellow, salmon, mauve, lavender lipgloss.Style
pill, pick, plain, border lipgloss.Style
}
func newTheme(dark bool) theme {
ld := lipgloss.LightDark(dark)
c := func(l, d string) color.Color { return ld(lipgloss.Color(l), lipgloss.Color(d)) }
fg := func(l, d string) lipgloss.Style { return lipgloss.NewStyle().Foreground(c(l, d)) }
t := theme{
text: fg("#4c4f69", "#cdd6f4"),
dim: fg("#9ca0b0", "#6c7086"),
lavender: fg("#7287fd", "#b4befe"),
mauve: fg("#8839ef", "#cba6f7"),
mint: fg("#40a02b", "#a6e3a1"),
yellow: fg("#df8e1d", "#f9e2af"),
salmon: fg("#d20f39", "#f38ba8"),
pill: lipgloss.NewStyle().Background(c("#7287fd", "#b4befe")).Foreground(c("#eff1f5", "#1e1e2e")).Padding(0, 1),
pick: lipgloss.NewStyle().Background(c("#ccd0da", "#313244")),
plain: lipgloss.NewStyle(),
}
t.faint, t.border = t.dim.Faint(true), t.dim
return t
}
// setTheme restyles the picker and its filter for a light or dark terminal
func (m *pickerModel) setTheme(dark bool) {
m.th = newTheme(dark)
st := textinput.DefaultStyles(dark)
st.Focused.Placeholder, st.Blurred.Placeholder = m.th.faint, m.th.faint
m.filter.SetStyles(st)
}
type hostRow struct {
name string
local bool
probing bool
peerOffline bool // tailscale says the host is off the mesh
unreachable bool // could not reach the host over ssh at all
started time.Time
duration time.Duration
sessions []session.Session
}
type probeResult struct {
row int
sessions []session.Session
err error
duration time.Duration
}
type pickerModel struct {
filter textinput.Model
rows []hostRow
cursor int
width int
height int // 0 until the first resize, meaning no known limit
th theme
spinner spinner.Model
twitch int
blinking bool
blinkAt int // tick the current blink started
nextBlink int
lookDir int // -1 left, +1 right; persists after the blink
holdTicks int // how long the gaze-hold lasts after a blink
probes chan probeResult
picked *pickEntry
killing map[string]bool // keyed by canonical name, in flight
notice string // the last kill failure, until the next key
}
type killResult struct {
sess session.Session
err error
}
type pickEntry struct {
host string
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), tea.RequestBackgroundColor)
}
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, m.height = msg.Width, msg.Height
m.filter.SetWidth(min(msg.Width, maxUIWidth) - 8)
m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width
case tea.BackgroundColorMsg:
m.setTheme(msg.IsDark())
case probeResult:
byNewest(msg.sessions)
r := &m.rows[msg.row]
r.probing, r.unreachable, r.duration, r.sessions = false, errors.Is(msg.err, remote.ErrUnreachable), msg.duration, msg.sessions
m.move(0)
if lo.SomeBy(m.rows, func(row hostRow) bool { return row.probing }) {
return m, waitForProbe(m.probes)
}
return m, nil
case spinner.TickMsg:
m.twitch++
if m.filter.Value() == "" {
if !m.blinking && m.twitch >= m.nextBlink {
m.blinking, m.blinkAt = true, m.twitch
if rand.IntN(2) == 0 {
m.lookDir = -m.lookDir // a chance to look the other way
}
m.holdTicks = 20 + rand.IntN(21)
}
if m.blinking && m.twitch-m.blinkAt >= 18+m.holdTicks {
m.blinking = false
m.nextBlink = m.twitch + 30 + rand.IntN(31)
}
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case killResult:
c := msg.sess.Canonical()
m.killing = lo.OmitByKeys(m.killing, []string{c})
if msg.err != nil {
m.notice = strings.Join(strings.Fields(msg.err.Error()), " ")
return m, nil
}
for i := range m.rows {
m.rows[i].sessions = lo.Reject(m.rows[i].sessions, func(s session.Session, _ int) bool { return s.Canonical() == c })
}
m.move(0)
return m, nil
case tea.KeyPressMsg:
m.notice = ""
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "ctrl+x":
return m.kill()
case "up":
m.move(-1)
return m, nil
case "down":
m.move(1)
return m, nil
case "enter":
if p := m.pick(); p != nil {
m.picked = p
return m, tea.Quit
}
return m, nil
case "esc":
if m.filter.Value() == "" {
return m, tea.Quit
}
m.filter.SetValue("")
m.cursor = 0
}
var cmd tea.Cmd
before := m.filter.Value()
m.filter, cmd = m.filter.Update(msg)
if m.filter.Value() != before {
m.cursor = 0
}
return m, cmd
}
return m, nil
}
func (m pickerModel) kill() (tea.Model, tea.Cmd) {
p := m.pick()
if p == nil || p.sess == nil || m.killing[p.sess.Canonical()] {
return m, nil
}
s := *p.sess
m.killing = lo.Assign(m.killing, map[string]bool{s.Canonical(): true})
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return killResult{s, Kill(ctx, s)}
}
}
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) // a host that matches keeps all of its sessions
} else if hits := rankSessions(r.sessions, q, false); len(hits) > 0 {
r.sessions = hits
out = append(out, r)
}
}
return out
}
// in draw order; 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 }
// move parks the cursor at zero when a filter left nothing to pick
func (m *pickerModel) move(dir int) { m.cursor = max(0, min(len(m.entries())-1, m.cursor+dir)) }
func (m pickerModel) pick() *pickEntry {
if e := m.entries(); m.cursor >= 0 && m.cursor < len(e) {
return &e[m.cursor]
}
return nil
}
var kittyFace = [...]string{"(^% % ^)", "(^ % % ^)", "(^ % %^)"}
const eyeDozing, eyeOpen, eyeLooking = "-", "•", "."
func makeKitty(dir int, char string) string {
return strings.ReplaceAll(kittyFace[min(2, max(0, dir+1))], "%", char)
}
func (m pickerModel) View() tea.View { return tea.NewView(m.view()) }
func (m pickerModel) view() string {
w := min(m.width, maxUIWidth)
if w < 24 {
return ""
}
var b strings.Builder
counts := fmt.Sprintf("%d host%s · %d active ", len(m.rows), plural(len(m.rows)), lo.SumBy(m.rows, func(r hostRow) int { return len(r.sessions) }))
kitty := makeKitty(m.lookDir, eyeDozing)
if m.filter.Value() != "" {
kitty = makeKitty(m.filter.Position()/10-1, eyeLooking)
} else if m.blinking {
if t := m.twitch - m.blinkAt; t < 10 || t >= 12 && t < 14 { // eyes open, straight
kitty = makeKitty(m.lookDir, eyeOpen)
}
}
title := m.th.mauve.Render(kitty+" ") + m.th.text.Bold(true).Render("tobi ")
wt := utf8.RuneCountInString(kitty) + 6
mid := strings.Repeat("─", max(1, w-5-wt))
if wt+utf8.RuneCountInString(counts) <= w-6 {
mid = strings.Repeat("─", w-6-wt-utf8.RuneCountInString(counts)) + " " + counts
}
b.WriteString("╭─ " + title + m.th.border.Render(mid+"─╮") + "\n")
filter := m.filter.View()
b.WriteString(m.th.border.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + m.th.border.Render(" │") + "\n")
b.WriteString(m.th.border.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n")
rows := m.rowsFor()
if len(rows) == 0 {
b.WriteString(" " + m.th.faint.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n")
}
m.drawHostTable(&b, w, rows)
b.WriteString(m.th.border.Render(strings.Repeat("─", w)) + "\n")
if m.notice != "" {
b.WriteString(m.th.salmon.MaxWidth(w).Render(m.notice))
return b.String()
}
hs := m.hints()
span := func(hs []pickerHint) int { return lo.SumBy(hs, pickerHint.width) + 3*(len(hs)-1) }
for len(hs) > 1 && span(hs) > w {
hs = hs[:len(hs)-1]
}
free, gaps := w-lo.SumBy(hs, pickerHint.width), max(1, len(hs)-1)
for i, h := range hs {
if i > 0 {
b.WriteString(strings.Repeat(" ", free/gaps+lo.Ternary(i <= free%gaps, 1, 0)))
}
b.WriteString(m.th.dim.Render("[") + m.th.text.Render(h.k) + m.th.dim.Render("] "+h.act))
}
return b.String()
}
type pickerHint struct{ k, act string }
func (h pickerHint) width() int { return utf8.RuneCountInString(h.k+h.act) + 3 } // the brackets and one gap
func (m pickerModel) hints() []pickerHint {
hs := []pickerHint{{"enter", "attach"}, {"↑↓", "move"}, {"esc", "clear"}, {"ctrl+c", "quit"}}
switch p := m.pick(); {
case p == nil:
case p.sess == nil:
hs[0].act = "new shell"
default:
hs = slices.Insert(hs, 2, pickerHint{"ctrl+x", "kill"})
}
return hs
}
// the cursor row is one background run: nested colour resets would punch holes in it
func (t theme) writeLine(b *strings.Builder, w int, left, right string, sel bool) {
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 1 // a row longer than the width still keeps its columns apart
}
line := left + strings.Repeat(" ", gap) + right
if sel {
line = t.pick.Render(line)
}
b.WriteString(line + "\n")
}
func (m pickerModel) sessionStatus(s session.Session, sel bool) string {
if !m.killing[s.Canonical()] {
return relTime(s.StartedAt)
}
return lo.Ternary(sel, "killing…", m.th.salmon.Render("killing…"))
}
func (m pickerModel) sessionRow(b *strings.Builder, w int, s session.Session, sel bool, right string) {
pad, idSt, cmdSt, cwdSt := " ", m.th.mauve, m.th.lavender, m.th.yellow
if sel {
pad, idSt, cmdSt, cwdSt = " › ", m.th.plain, m.th.plain, m.th.plain
}
left := pad + idSt.Render(s.ID) + " " + cmdSt.Render(s.Command) + " " + cwdSt.Render(s.Cwd)
m.th.writeLine(b, w, left, right, sel)
}
type hostLine struct {
glyph, sess, ping string
glyphSt, sessSt, nameSt, pingSt lipgloss.Style
}
func (t theme) hostLine(r hostRow, spin string) hostLine {
l := hostLine{
glyph: "○", sess: "no sessions", ping: fmt.Sprintf("%.1fs", r.duration.Seconds()),
glyphSt: t.dim, sessSt: t.dim, nameSt: t.text, pingSt: t.dim,
}
switch {
case r.probing:
l.sess = "searching"
l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds())
case r.unreachable:
l.glyph, l.sess = "!", "unreachable"
l.glyphSt, l.sessSt, l.nameSt, l.pingSt = t.salmon, t.salmon.Faint(true), t.faint, t.salmon.Faint(true)
case r.peerOffline:
l.glyph, l.sess, l.ping = "×", "offline", ""
l.glyphSt, l.sessSt, l.nameSt = t.faint, t.faint, t.faint
case len(r.sessions) > 0:
l.glyph, l.sess = "●", fmt.Sprintf("%d session%s", len(r.sessions), plural(len(r.sessions)))
l.glyphSt, l.sessSt = t.mint, t.mint.Faint(true)
}
return l
}
// the header, the filter box, the divider and the hints drawn around the table
const chromeLines = 5
// scrollWindow picks the avail of n lines to draw, centred on the cursor's
// line and clamped to the ends
func scrollWindow(n, at, avail int) (from, to int) {
if n <= avail {
return 0, n
}
from = max(0, min(n-avail, at-avail/2))
return from, from + avail
}
// the table only draws the lines that fit: a taller view loses its top to
// the renderer, taking the filter box and the cursor with it
func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) {
var t strings.Builder
nameW := min(22, lo.Max(lo.Map(rows, func(r hostRow, _ int) int { return utf8.RuneCountInString(r.name) })))
cursor, at := 0, 0 // the entries() index being drawn, and the table line holding the cursor
for _, r := range rows {
l := m.th.hostLine(r, m.spinner.View())
reserve := 5 + lo.Ternary(r.local, 8, 0) // the local pill and its gap
sessW := min(12, max(0, w-3-2-nameW-3-reserve))
sel := selectableHost(r) && cursor == m.cursor
if selectableHost(r) {
cursor++
}
pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, m.th.pill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt
if sel {
pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), m.th.plain, m.th.plain
at = strings.Count(t.String(), "\n")
}
m.th.writeLine(&t, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel)
for _, s := range r.sessions {
sel := cursor == m.cursor
if sel {
at = strings.Count(t.String(), "\n")
}
m.sessionRow(&t, w, s, sel, m.sessionStatus(s, sel))
cursor++
}
}
lines := strings.SplitAfter(t.String(), "\n")
lines = lines[:len(lines)-1] // the empty tail after the last newline
from, to := scrollWindow(len(lines), at, lo.Ternary(m.height > 0, max(1, m.height-chromeLines), len(lines)))
b.WriteString(strings.Join(lines[from:to], ""))
}
func runPicker(local []session.Session, initial string) error {
peersCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
peers := remote.DiscoverPeers(peersCtx) // never includes this host
cancel()
filter := textinput.New()
filter.Prompt = "› "
filter.Placeholder = "search sessions…"
filter.Focus()
filter.SetWidth(startWidth - 8)
filter.SetValue(strings.TrimSpace(initial))
m := pickerModel{
filter: filter,
width: startWidth,
probes: make(chan probeResult, len(peers)),
spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
rows: []hostRow{{name: strings.ToLower(session.ShortHostname()), local: true, sessions: local}},
}
m.setTheme(true) // dark until the terminal answers the background query
m.lookDir, m.nextBlink = []int{-1, 1}[rand.IntN(2)], 30+rand.IntN(31)
online, offline := lo.FilterReject(peers, func(p remote.Peer, _ int) bool { return p.Online })
for _, p := range online {
for _, dest := range remote.ProbeDests(p.Name) {
go probe(m.probes, len(m.rows), dest)
m.rows = append(m.rows, hostRow{name: dest, probing: true, started: time.Now()})
}
}
if os.Getenv("TOBI_OFFLINE") == "1" {
for _, p := range offline {
m.rows = append(m.rows, hostRow{name: p.Name, peerOffline: true})
}
}
m.move(0)
model, err := tea.NewProgram(m).Run()
if err != nil {
return err
}
fin := model.(pickerModel)
switch p := fin.picked; {
case p == nil:
return nil
case p.sess != nil:
return connectOrHop(*p.sess)
}
fmt.Fprintln(os.Stderr, fin.th.dim.Render("starting a shell on "+fin.picked.host+"…"))
return Spawn(fin.picked.host, loginShell)
}
func probe(probes chan<- probeResult, row int, dest string) {
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
list, err := remote.QueryRemote(ctx, dest)
probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(start))}
}
// the target's own login shell, named so the picker need not show the wrapper
var loginShell = []string{"sh", "-lc", `printf '\033]2;shell\a'; exec "${SHELL:-/bin/sh}"`}
func relTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds ago", int(d.Seconds()))
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()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
func probeClock(d time.Duration) time.Duration { return min(d, 9900*time.Millisecond) }
func plural(n int) string { return lo.Ternary(n == 1, "", "s") }
func isTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) }