ui: kill sessions from the picker with ctrl+x
9 unmodified lines101112131414151617181927 unmodified lines9 unmodified lines
you can access it from any of your other devices on your tailscale network byrunning `tobi`, which opens up a fuzzy selector with all the sessions acrossyour network, one row per host: enter on a host row starts a login shell there,so you can open a session without leaving the picker. hosts that are offline orso you can open a session without leaving the picker, and ctrl+x on a sessionkills it. hosts that are offline orunreachable are listed but can't be picked. you can also just do `tobi hx`,`tobi host.<TAB>` or `tobi hs.id` to attach a session using fuzzy search.
unlike tmux, tobi doesn't nest. running tobi inside a tobi session will just27 unmodified lines149 unmodified linesExpand all15015115215315415415615515615715815919 unmodified lines17918018118218318418518618718818919019119219319419519619752 unmodified lines149 unmodified linesExpand all } } }()
self := strings.ToLower(session.ShortHostname()) dial := func(s session.Session) (io.ReadWriteCloser, error) { if strings.EqualFold(s.Host, self) || strings.EqualFold(s.Host, "localhost") { if isLocal(s.Host) { return net.Dial("unix", session.SocketPath(s.ID)) } return remote.AttachRemote(s.Host, s.ID) }19 unmodified lines current = session.Session{Host: t.Dest(), ID: t.ID} }}
func isLocal(host string) bool { return strings.EqualFold(host, session.ShortHostname()) || strings.EqualFold(host, "localhost")}
func Kill(ctx context.Context, s session.Session) error { if isLocal(s.Host) { return session.Kill(s.ID) } return remote.KillRemote(ctx, s.Host, s.ID)}
func Spawn(addr string, cmdArgs []string) error { if len(cmdArgs) == 0 { return errors.New("usage: tobi [host.[id]] -- <command> [args...]") }52 unmodified lines123456789101112131415161715 unmodified lines333435363738394041424344454647484950515253545556575859package app
import ( "errors" "regexp" "strings" "testing" "time" "unicode/utf8"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "tobi/internal/session")
var ansi = regexp.MustCompile("\\x1b\\[[0-9;]*m")15 unmodified lines } } }}
func TestKillFlow(t *testing.T) { a := session.Session{ID: "a", Host: "caladon", Command: "hx", StartedAt: time.Now()} m := pickerModel{width: 80, filter: textinput.New(), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{{name: "caladon", sessions: []session.Session{a}}}} m.move(1) next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) if m = next.(pickerModel); cmd == nil || !m.killing[a.Canonical()] { t.Fatal("ctrl+x should fire the kill") } if _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}); cmd != nil { t.Fatal("a kill in flight should not fire again") }
next, _ = m.Update(killResult{a, errors.New("remote tobi is\ntoo old")}) if m = next.(pickerModel); m.killing[a.Canonical()] || !strings.Contains(m.View(), "remote tobi is too old") { t.Fatal("a failed kill should clear the flight and show the error on one line") } next, _ = m.Update(killResult{a, nil}) if m = next.(pickerModel); len(m.rows[0].sessions) != 0 { t.Fatal("a killed session should leave its row") }}4 unmodified lines567891011121371 unmodified lines85868788899091929394959697989910010141 unmodified lines14314414514614714814915015115215315415515615715815916016116216316416516616716816917022 unmodified lines19319419519619719819920020120220320420520620720820921021121221321476 unmodified lines29129229329429529629729829930030130220 unmodified lines3233243253262853273283293303313323333343353369 unmodified lines34634734834935035135235335435535630435735835936036136231036336436536636739 unmodified lines40740840941035841141241341441541690 unmodified lines4 unmodified lines "errors" "fmt" "math/rand/v2" "os" "slices" "strings" "time" "unicode/utf8"
71 unmodified lines holdTicks int // how long the gaze-hold lasts after a blink probes chan probeResult pending int 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}41 unmodified lines 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.KeyMsg: m.notice = "" switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit case tea.KeyCtrlX: return m.kill() case tea.KeyUp: m.move(-1) return m, nil case tea.KeyDown:22 unmodified lines } 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.rows76 unmodified lines 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") if m.notice != "" { b.WriteString(salmonStyle.MaxWidth(w).Render(m.notice)) return b.String() } hint := func(k, act string) string { return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act) } hs := m.hints()20 unmodified linesfunc (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"}} if p := m.pick(); p != nil && p.sess == nil { 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}
9 unmodified lines } 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…", salmonStyle.Render("killing…"))}
func sessionRow(b *strings.Builder, w int, s session.Session, sel bool) {func sessionRow(b *strings.Builder, w int, s session.Session, sel bool, right string) { pad, idSt, cmdSt, cwdSt := " ", mauveStyle, lavenderStyle, yellowStyle if sel { pad, idSt, cmdSt, cwdSt = " › ", plainStyle, plainStyle, plainStyle } left := pad + idSt.Render(s.ID) + " " + cmdSt.Render(s.Command) + " " + cwdSt.Render(s.Cwd) writeLine(b, w, left, relTime(s.StartedAt), sel) writeLine(b, w, left, right, sel)}
type hostLine struct { glyph, sess, ping string39 unmodified lines } 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, s, cursor == m.cursor) sel := cursor == m.cursor sessionRow(b, w, s, sel, m.sessionStatus(s, sel)) cursor++ } }}90 unmodified lines15 unmodified lines16171819202122232430 unmodified lines15 unmodified lines TypeResize TypeHop TypeInfo TypeInfoResp TypeKill)
func ReadFrame(r io.Reader) (FrameType, []byte, error) { var h [5]byte30 unmodified lines141 unmodified linesExpand all142143144145146147148149150242 unmodified linesExpand all39339439539639739839940040140240340440540636 unmodified lines44344444544644744844945045145245345445545645745845946046146246321 unmodified lines141 unmodified linesExpand all
const ( uploadMarker = "__TOBI_UPLOADED__" spawnMarker = "__TOBI_SPAWNED__" killMarker = "__TOBI_KILLED__" remotePathEnv = `PATH="$HOME/.local/bin:$PATH:$HOME/.nix-profile/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/go/bin"` uploadCmd = "mkdir -p ~/.local/bin && cat > ~/.local/bin/tobi.tmp && chmod 755 ~/.local/bin/tobi.tmp && mv -f ~/.local/bin/tobi.tmp ~/.local/bin/tobi")
242 unmodified linesExpand all return runSpawnRPC(os.Stdin) case "version": fmt.Println(Version) return nil case "kill": if err := session.Kill(id); err != nil { return err } fmt.Println(killMarker) return nil } conn, err := net.Dial("unix", session.SocketPath(id)) if err != nil { return err36 unmodified lines } return session.EnsureOwner(req.ID, req.Argv)}
// an older remote tobi takes "kill" for an attach and exits clean, so only the marker proves itfunc KillRemote(ctx context.Context, host, id string) error { dest := hostDest(host) out, err := sshRun(ctx, dest, nil, sshOpts{cmd: rpcCommand("kill", id), batch: true, timeout: "5", master: true}) if err == nil && !strings.Contains(out, killMarker) { err = errors.New("remote tobi is too old to kill sessions") } if err != nil { return fmt.Errorf("kill %s.%s: %w", dest, id, err) } return nil}
func SpawnRemote(host, id string, cmdArgs []string) error { if !session.ValidateID(id) { return fmt.Errorf("invalid session id: %q", id) }21 unmodified lines240 unmodified linesExpand all241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272257 unmodified linesExpand all530531532533534535536537538539145 unmodified linesExpand all240 unmodified linesExpand all
return protocol.WriteFrame(conn, protocol.TypeHop, []byte(target))}
// an owner that predates TypeKill just hangs up, so a socket that still answers means it livedfunc Kill(id string) error { sock := SocketPath(id) if sock == "" { return fmt.Errorf("invalid session id: %q", id) } conn, err := net.DialTimeout("unix", sock, 250*time.Millisecond) if err != nil { return fmt.Errorf("session %q is not running", id) } _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) err = protocol.WriteFrame(conn, protocol.TypeKill, nil) _, _ = io.Copy(io.Discard, conn) _ = conn.Close() if err != nil { return err } if c, err := net.DialTimeout("unix", sock, 250*time.Millisecond); err == nil { _ = c.Close() return fmt.Errorf("session %q outlived the kill, its owner is too old to take one", id) } return nil}
type outMsg struct { t protocol.FrameType p []byte}257 unmodified linesExpand all o.mu.Unlock() _ = protocol.WriteFrame(conn, protocol.TypeInfoResp, data) case protocol.TypeHop: o.hop(payload) case protocol.TypeKill: o.Close() } _ = conn.Close() return }145 unmodified linesExpand all59 unmodified lines606162636465666768697071727374757677787980818285 unmodified lines59 unmodified lines } }}
func TestKill(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sleep", "60") id := strings.TrimSuffix(filepath.Base(sp), ".sock") if err := Kill(id); err != nil { t.Fatal(err) } if list := ListLocal(); len(list) != 0 { t.Fatalf("session survived: %v", list) } if err := Kill(id); err == nil { t.Fatal("killing a dead session succeeded") }}
func TestQueryFilter(t *testing.T) { var f queryFilter for _, tc := range []struct{ in, want string }{ {"\x1b]10", ""}, {";?\x1b\\", ""},85 unmodified lines