mauveStyle = lipgloss.NewStyle().Foreground(mauve)
lavenderStyle = lipgloss.NewStyle().Foreground(lavender)
selfPill = lipgloss.NewStyle().Background(lavender).Foreground(base).Padding(0, 1)
pickStyle = lipgloss.NewStyle().Background(surface)
plainStyle = lipgloss.NewStyle()
borderStyle = lipgloss.NewStyle().Foreground(overlay)
)
const (
maxUIWidth = 76
startWidth = 80 // assumed until the first WindowSizeMsg lands
startWidth = 80
)
type hostRow struct {
name string
picked *pickEntry
}
type pickEntry struct {
host string // ssh destination of the host the line belongs to
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)) }
r.duration = msg.duration
byNewest(msg.sessions)
r.sessions = msg.sessions
m.pending--
m.clamp()
m.move(0)
if m.pending > 0 {
return m, waitForProbe(m.probes)
}
return m, nil
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyUp:
m.cursor = max(0, m.cursor-1)
m.move(-1)
return m, nil
case tea.KeyDown:
m.cursor = min(len(m.entries())-1, m.cursor+1)
m.move(1)
return m, nil
case tea.KeyEnter:
if e := m.entries(); m.cursor < len(e) {
m.picked = &e[m.cursor]
if p := m.pick(); p != nil {
m.picked = p
return m, tea.Quit
}
return m, nil
case tea.KeyEsc:
}
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 {
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, so the cursor and the table cannot disagree; hosts we cannot
// reach are drawn but not selectable
// 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) {
}
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)) }
// 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 = "-", "•", "."
hint := func(k, act string) string {
return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act)
}
hs := m.hints()
total := 0
for _, h := range hs {
total += h.w
}
for len(hs) > 1 && total+3*(len(hs)-1) > w {
total -= hs[len(hs)-1].w
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]
}
col := max(1, (w-hs[len(hs)-1].w)/(len(hs)-1))
col := max(1, (w-hs[len(hs)-1].width())/max(1, len(hs)-1)) // one hint is pinned right, so col is unused
x := 0
for i, h := range hs {
next := i * col
if i == len(hs)-1 {
next = w - h.w
next = w - h.width()
}
b.WriteString(strings.Repeat(" ", max(0, next-x)))
b.WriteString(hint(h.k, h.act))
x = max(next, x) + h.w // a hint that overran its column pushes the rest right
x = max(next, x) + h.width()
}
return b.String()
}
type pickerHint struct {
k, act string
w int
}
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 {
act := "attach"
if e := m.entries(); m.cursor < len(e) && e[m.cursor].sess == nil {
act = "new shell"
hs := []pickerHint{{"enter", "attach"}, {"↑↓", "move"}, {"esc", "clear"}, {"ctrl+c", "quit"}}
if p := m.pick(); p != nil && p.sess == nil {
hs[0].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)
// the cursor row is one background run: nested colour resets would punch holes in it
func 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 = pickStyle.Render(line)
}
b.WriteString(line + "\n")
}
func sessionRow(b *strings.Builder, w, cursor, sel int, s session.Session, label, pad, selPad string) {
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
func sessionRow(b *strings.Builder, w int, s session.Session, sel bool) {
pad, idSt, cmdSt, cwdSt := " ", mauveStyle, lavenderStyle, yellowStyle
if sel {
pad, idSt, cmdSt, cwdSt = " › ", plainStyle, plainStyle, plainStyle
}
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")
left := pad + idSt.Render(s.ID) + " " + cmdSt.Render(s.Command) + " " + cwdSt.Render(s.Cwd)
writeLine(b, w, left, relTime(s.StartedAt), sel)
}
type hostLine struct {
glyph, sess, ping string
glyphSt, sessSt, nameSt, pingSt lipgloss.Style
}
func (r hostRow) line(spin string) hostLine {
l := hostLine{
glyph: "○", sess: "no sessions", ping: fmt.Sprintf("%.1fs", r.duration.Seconds()),
glyphSt: dimStyle, sessSt: dimStyle, nameSt: textStyle, pingSt: dimStyle,
}
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 = salmonStyle, salmonStyle.Faint(true), faintStyle, salmonStyle.Faint(true)
case r.peerOffline:
l.glyph, l.sess, l.ping = "×", "offline", ""
l.glyphSt, l.sessSt, l.nameSt = faintStyle, faintStyle, faintStyle
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 = mintStyle, mintStyle.Faint(true)
}
return l
}
func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) {
nameW := 0
nameW := min(22, lo.Max(lo.Map(rows, func(r hostRow, _ int) int { return utf8.RuneCountInString(r.name) })))
cursor := 0 // the entries() index of the line being drawn
for _, r := range rows {
nameW = max(nameW, utf8.RuneCountInString(r.name))
}
nameW = min(nameW, 22)
cursor := 0
for _, r := range rows {
glyph, sess := "○", "no sessions"
glyphSt, sessSt, nameSt := dimStyle, dimStyle, textStyle
pingSt := dimStyle
ping := fmt.Sprintf("%.1fs", r.duration.Seconds())
switch {
case r.probing:
sess = "searching"
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
pingSt = salmonStyle.Faint(true)
case r.peerOffline:
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)))
glyphSt, sessSt = mintStyle, mintStyle.Faint(true)
}
reserve := 5
if r.local {
reserve += 8 // the local pill and its gap
}
l := r.line(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))
selected := selectableHost(r) && cursor == m.cursor
sel := 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")
pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, selfPill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt
if sel {
pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), plainStyle, plainStyle
}
writeLine(b, w, pad+nameSt.Width(nameW).Render(r.name)+" "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel)
for _, s := range r.sessions {
sessionRow(b, w, cursor, m.cursor, s, s.ID, " ", " › ")
sessionRow(b, w, s, cursor == m.cursor)
cursor++
}
}
}
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
filter.Width = startWidth - 8
if initial = strings.TrimSpace(initial); initial != "" {
filter.SetValue(initial)
}
probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(m.rows[row].started))}
}(i, m.rows[i].name)
}
m.clamp()
m.move(0)
model, err := tea.NewProgram(m).Run()
if err != nil {
return err
}
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}"`}
// 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 {