dashboard / pico / refactor(tui): use vaxis #48 rss

open · opened on 2025-02-24T12:55:08Z by erock
Help
# add changes to patch request
git format-patch main --stdout | ssh pr.pico.sh pr add 48
# add review to patch request
git format-patch main --stdout | ssh pr.pico.sh pr add --review 48
# remove patchset
ssh pr.pico.sh ps rm ps-x
# checkout all patches
ssh pr.pico.sh pr print 48 | git am -3
# print a diff between the last two patches in a patch request
ssh pr.pico.sh pr diff 48
# accept PR
ssh pr.pico.sh pr accept 48
# close PR
ssh pr.pico.sh pr close 48

Logs

erock created pr with ps-105 on 2025-02-24T12:55:08Z
erock added ps-106 on 2025-02-25T01:16:25Z

Patchsets

ps-105 by erock on 2025-02-24T12:55:08Z
Range Diff ↕ rd-106
1: 9033000 = 1: 9033000 refactor(tui): use vaxis
-: ------- > 2: e191f12 chore(tui): setup page navigation
ps-106 by erock on 2025-02-25T01:16:25Z

Patchset ps-106

refactor(tui): use vaxis

Eric Bower
2025-02-24T12:47:08Z
go.mod
+1 -1
tuivax/ui.go
+30 -0

chore(tui): setup page navigation

Eric Bower
2025-02-25T01:15:47Z
go.mod
+3 -3
go.sum
+0 -2
tuivax/ui.go
+90 -13
Back to top

refactor(tui): use vaxis

While we really enjoyed the charm stack for our ssh apps, we are at a
point where we want to reduce our overall dependencies for our SSH apps.

With charm we have:

- `crypto/ssh`
- `gliberlabs/ssh`
- `charmbracelet/ssh`
- `charmbracelet/wish`

There's a lot that can go wrong here and we have seen quite a bit of
thrashing within these libraries that required us to make moderate
changes when upgrading.

We also enjoyed bubbletea/lipgloss but we are at the point where we would like to
switch to `vaxis` since it is more inline with our design ethos.

We are basically going to replace 5 go packages with 1 and we are
starting with the TUI.
cmd/vaxis/ssh/main.go link
+7 -0
 1diff --git a/cmd/vaxis/ssh/main.go b/cmd/vaxis/ssh/main.go
 2new file mode 100644
 3index 0000000..b176bd3
 4--- /dev/null
 5+++ b/cmd/vaxis/ssh/main.go
 6@@ -0,0 +1,7 @@
 7+package main
 8+
 9+import "github.com/picosh/pico/pico"
10+
11+func main() {
12+	pico.StartSshServerVaxis()
13+}
go.mod link
+1 -1
 1diff --git a/go.mod b/go.mod
 2index 22d6c79..42579e7 100644
 3--- a/go.mod
 4+++ b/go.mod
 5@@ -26,6 +26,7 @@ replace git.sr.ht/~rockorager/vaxis => github.com/antoniomika/vaxis v0.0.0-20250
 6 
 7 require (
 8 	git.sr.ht/~delthas/senpai v0.3.1-0.20240425235039-206be659439e
 9+	git.sr.ht/~rockorager/vaxis v0.10.3
10 	github.com/alecthomas/chroma/v2 v2.14.0
11 	github.com/antoniomika/syncmap v1.0.0
12 	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
13@@ -79,7 +80,6 @@ require (
14 	codeberg.org/emersion/go-scfg v0.1.0 // indirect
15 	dario.cat/mergo v1.0.0 // indirect
16 	filippo.io/edwards25519 v1.1.0 // indirect
17-	git.sr.ht/~rockorager/vaxis v0.10.3 // indirect
18 	github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
19 	github.com/DavidGamba/go-getoptions v0.31.0 // indirect
20 	github.com/Masterminds/goutils v1.1.1 // indirect
pico/ssh_vaxis.go link
+126 -0
  1diff --git a/pico/ssh_vaxis.go b/pico/ssh_vaxis.go
  2new file mode 100644
  3index 0000000..37fee51
  4--- /dev/null
  5+++ b/pico/ssh_vaxis.go
  6@@ -0,0 +1,126 @@
  7+package pico
  8+
  9+import (
 10+	"context"
 11+	"fmt"
 12+	"os"
 13+	"os/signal"
 14+	"syscall"
 15+	"time"
 16+
 17+	"git.sr.ht/~rockorager/vaxis"
 18+	"github.com/charmbracelet/promwish"
 19+	"github.com/charmbracelet/ssh"
 20+	"github.com/charmbracelet/wish"
 21+	"github.com/picosh/pico/db/postgres"
 22+	"github.com/picosh/pico/shared"
 23+	"github.com/picosh/pico/tuivax"
 24+	wsh "github.com/picosh/pico/wish"
 25+	"github.com/picosh/send/auth"
 26+	"github.com/picosh/send/list"
 27+	"github.com/picosh/send/pipe"
 28+	wishrsync "github.com/picosh/send/protocols/rsync"
 29+	"github.com/picosh/send/protocols/scp"
 30+	"github.com/picosh/send/protocols/sftp"
 31+	"github.com/picosh/send/proxy"
 32+	"github.com/picosh/utils"
 33+)
 34+
 35+func createRouterVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler) proxy.Router {
 36+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
 37+		return []wish.Middleware{
 38+			pipe.Middleware(handler, ""),
 39+			list.Middleware(handler),
 40+			scp.Middleware(handler),
 41+			wishrsync.Middleware(handler),
 42+			auth.Middleware(handler),
 43+			wsh.PtyMdw(createTui()),
 44+			WishMiddleware(cliHandler),
 45+			wsh.LogMiddleware(handler.GetLogger()),
 46+		}
 47+	}
 48+}
 49+
 50+func withProxyVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler, otherMiddleware ...wish.Middleware) ssh.Option {
 51+	return func(server *ssh.Server) error {
 52+		err := sftp.SSHOption(handler)(server)
 53+		if err != nil {
 54+			return err
 55+		}
 56+
 57+		return proxy.WithProxy(createRouterVaxis(cfg, handler, cliHandler), otherMiddleware...)(server)
 58+	}
 59+}
 60+
 61+func createTui() wish.Middleware {
 62+	return func(next ssh.Handler) ssh.Handler {
 63+		return func(sesh ssh.Session) {
 64+			vty, err := shared.NewVConsole(sesh)
 65+			if err != nil {
 66+				panic(err)
 67+			}
 68+			opts := vaxis.Options{
 69+				WithConsole: vty,
 70+			}
 71+			tuivax.NewTui(opts)
 72+		}
 73+	}
 74+}
 75+
 76+func StartSshServerVaxis() {
 77+	host := utils.GetEnv("PICO_HOST", "0.0.0.0")
 78+	port := utils.GetEnv("PICO_SSH_PORT", "2222")
 79+	promPort := utils.GetEnv("PICO_PROM_PORT", "9222")
 80+	cfg := NewConfigSite()
 81+	logger := cfg.Logger
 82+	dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 83+	defer dbpool.Close()
 84+
 85+	handler := NewUploadHandler(
 86+		dbpool,
 87+		cfg,
 88+	)
 89+	cliHandler := &CliHandler{
 90+		Logger: logger,
 91+		DBPool: dbpool,
 92+	}
 93+
 94+	sshAuth := shared.NewSshAuthHandler(dbpool, logger)
 95+	s, err := wish.NewServer(
 96+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
 97+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
 98+		wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
 99+			sshAuth.PubkeyAuthHandler(ctx, key)
100+			return true
101+		}),
102+		withProxyVaxis(
103+			cfg,
104+			handler,
105+			cliHandler,
106+			promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "pico-ssh"),
107+		),
108+	)
109+	if err != nil {
110+		logger.Error(err.Error())
111+		return
112+	}
113+
114+	done := make(chan os.Signal, 1)
115+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
116+	logger.Info("starting SSH server on", "host", host, "port", port)
117+	go func() {
118+		if err = s.ListenAndServe(); err != nil {
119+			logger.Error("serve", "err", err.Error())
120+			os.Exit(1)
121+		}
122+	}()
123+
124+	<-done
125+	logger.Info("stopping SSH server")
126+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
127+	defer func() { cancel() }()
128+	if err := s.Shutdown(ctx); err != nil {
129+		logger.Error("shutdown", "err", err.Error())
130+		os.Exit(1)
131+	}
132+}
shared/senpai.go link
+11 -2
 1diff --git a/shared/senpai.go b/shared/senpai.go
 2index 016ff10..3c6f842 100644
 3--- a/shared/senpai.go
 4+++ b/shared/senpai.go
 5@@ -123,10 +123,9 @@ func (v *VConsole) Close() error {
 6 	return err
 7 }
 8 
 9-func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error) {
10+func NewVConsole(sesh ssh.Session) (*VConsole, error) {
11 	pty, win, ok := sesh.Pty()
12 	if !ok {
13-		slog.Error("PTY not found")
14 		return nil, fmt.Errorf("PTY not found")
15 	}
16 
17@@ -176,6 +175,16 @@ func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error)
18 		}
19 	}()
20 
21+	return vty, nil
22+}
23+
24+func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error) {
25+	vty, err := NewVConsole(sesh)
26+	if err != nil {
27+		slog.Error("PTY not found")
28+		return nil, err
29+	}
30+
31 	senpaiCfg := senpai.Defaults()
32 	senpaiCfg.TLS = true
33 	senpaiCfg.Addr = "irc.pico.sh:6697"
tuivax/ui.go link
+30 -0
 1diff --git a/tuivax/ui.go b/tuivax/ui.go
 2new file mode 100644
 3index 0000000..538b789
 4--- /dev/null
 5+++ b/tuivax/ui.go
 6@@ -0,0 +1,30 @@
 7+package tuivax
 8+
 9+import (
10+	"git.sr.ht/~rockorager/vaxis"
11+)
12+
13+func NewTui(opts vaxis.Options) {
14+	vx, err := vaxis.New(opts)
15+	if err != nil {
16+		panic(err)
17+	}
18+	defer vx.Close()
19+	for ev := range vx.Events() {
20+		switch ev := ev.(type) {
21+		case vaxis.Key:
22+			switch ev.String() {
23+			case "Ctrl+c":
24+				return
25+			case "q":
26+				return
27+			case "Escape":
28+				return
29+			}
30+		}
31+		win := vx.Window()
32+		win.Clear()
33+		win.Print(vaxis.Segment{Text: "Hello, World!"})
34+		vx.Render()
35+	}
36+}

chore(tui): setup page navigation

go.mod link
+3 -3
 1diff --git a/go.mod b/go.mod
 2index 42579e7..9b199b5 100644
 3--- a/go.mod
 4+++ b/go.mod
 5@@ -16,17 +16,17 @@ go 1.23.1
 6 
 7 // replace git.sr.ht/~delthas/senpai => ../../senpai
 8 
 9-// replace git.sr.ht/~rockorager/vaxis => ../../vaxis
10+replace git.sr.ht/~rockorager/vaxis => ../../../src/vaxis
11 
12 // replace github.com/charmbracelet/wish => ../../wish
13 
14 replace git.sr.ht/~delthas/senpai => github.com/antoniomika/senpai v0.0.0-20250114180426-3061ddccec76
15 
16-replace git.sr.ht/~rockorager/vaxis => github.com/antoniomika/vaxis v0.0.0-20250114030546-8524674789ca
17+// replace git.sr.ht/~rockorager/vaxis => github.com/antoniomika/vaxis v0.0.0-20250114030546-8524674789ca
18 
19 require (
20 	git.sr.ht/~delthas/senpai v0.3.1-0.20240425235039-206be659439e
21-	git.sr.ht/~rockorager/vaxis v0.10.3
22+	git.sr.ht/~rockorager/vaxis v0.12.1-0.20250219164615-8231ece877f3
23 	github.com/alecthomas/chroma/v2 v2.14.0
24 	github.com/antoniomika/syncmap v1.0.0
25 	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
go.sum link
+0 -2
 1diff --git a/go.sum b/go.sum
 2index 89d665e..00c9214 100644
 3--- a/go.sum
 4+++ b/go.sum
 5@@ -68,8 +68,6 @@ github.com/antoniomika/senpai v0.0.0-20250114180426-3061ddccec76 h1:WAGPWAxThUoz
 6 github.com/antoniomika/senpai v0.0.0-20250114180426-3061ddccec76/go.mod h1:HIPY7uPDy44ep6xKwfsW6JTmDUTiC5DXh0l4yZP/aT4=
 7 github.com/antoniomika/syncmap v1.0.0 h1:iFSfbQFQOvHZILFZF+hqWosO0no+W9+uF4y2VEyMKWU=
 8 github.com/antoniomika/syncmap v1.0.0/go.mod h1:fK2829foEYnO4riNfyUn0SHQZt4ue3DStYjGU+sJj38=
 9-github.com/antoniomika/vaxis v0.0.0-20250114030546-8524674789ca h1:vD1Ioetf2ikrlkvHu7FIpbPgtMIJpaU16YRR+HA+/2Y=
10-github.com/antoniomika/vaxis v0.0.0-20250114030546-8524674789ca/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
11 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
12 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
13 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
pico/ssh_vaxis.go link
+13 -4
 1diff --git a/pico/ssh_vaxis.go b/pico/ssh_vaxis.go
 2index 37fee51..d109539 100644
 3--- a/pico/ssh_vaxis.go
 4+++ b/pico/ssh_vaxis.go
 5@@ -14,6 +14,7 @@ import (
 6 	"github.com/charmbracelet/wish"
 7 	"github.com/picosh/pico/db/postgres"
 8 	"github.com/picosh/pico/shared"
 9+	"github.com/picosh/pico/tui/common"
10 	"github.com/picosh/pico/tuivax"
11 	wsh "github.com/picosh/pico/wish"
12 	"github.com/picosh/send/auth"
13@@ -27,14 +28,22 @@ import (
14 )
15 
16 func createRouterVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler) proxy.Router {
17-	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
18+	return func(sh ssh.Handler, sesh ssh.Session) []wish.Middleware {
19+		shrd := &common.SharedModel{
20+			Session: sesh,
21+			Cfg:     cfg,
22+			Dbpool:  handler.DBPool,
23+			Width:   80,
24+			Height:  24,
25+			Logger:  cfg.Logger,
26+		}
27 		return []wish.Middleware{
28 			pipe.Middleware(handler, ""),
29 			list.Middleware(handler),
30 			scp.Middleware(handler),
31 			wishrsync.Middleware(handler),
32 			auth.Middleware(handler),
33-			wsh.PtyMdw(createTui()),
34+			wsh.PtyMdw(createTui(shrd)),
35 			WishMiddleware(cliHandler),
36 			wsh.LogMiddleware(handler.GetLogger()),
37 		}
38@@ -52,7 +61,7 @@ func withProxyVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *
39 	}
40 }
41 
42-func createTui() wish.Middleware {
43+func createTui(shrd *common.SharedModel) wish.Middleware {
44 	return func(next ssh.Handler) ssh.Handler {
45 		return func(sesh ssh.Session) {
46 			vty, err := shared.NewVConsole(sesh)
47@@ -62,7 +71,7 @@ func createTui() wish.Middleware {
48 			opts := vaxis.Options{
49 				WithConsole: vty,
50 			}
51-			tuivax.NewTui(opts)
52+			tuivax.NewTui(opts, shrd)
53 		}
54 	}
55 }
tui/pages/pages.go link
+8 -1
 1diff --git a/tui/pages/pages.go b/tui/pages/pages.go
 2index 0859b25..e5a1861 100644
 3--- a/tui/pages/pages.go
 4+++ b/tui/pages/pages.go
 5@@ -1,6 +1,9 @@
 6 package pages
 7 
 8-import tea "github.com/charmbracelet/bubbletea"
 9+import (
10+	"git.sr.ht/~rockorager/vaxis/vxfw"
11+	tea "github.com/charmbracelet/bubbletea"
12+)
13 
14 type Page int
15 
16@@ -27,6 +30,10 @@ func Navigate(page Page) tea.Cmd {
17 	}
18 }
19 
20+func NavigateVx(page Page) vxfw.Command {
21+	return NavigateMsg{page}
22+}
23+
24 func ToTitle(page Page) string {
25 	switch page {
26 	case CreateAccountPage:
tuivax/ui.go link
+90 -13
  1diff --git a/tuivax/ui.go b/tuivax/ui.go
  2index 538b789..a4d25c4 100644
  3--- a/tuivax/ui.go
  4+++ b/tuivax/ui.go
  5@@ -1,30 +1,107 @@
  6 package tuivax
  7 
  8 import (
  9+	"fmt"
 10+
 11 	"git.sr.ht/~rockorager/vaxis"
 12+	"git.sr.ht/~rockorager/vaxis/widgets/list"
 13+	"github.com/picosh/pico/tui/common"
 14 )
 15 
 16-func NewTui(opts vaxis.Options) {
 17+var menuChoices = []string{
 18+	"pubkeys",
 19+	"tokens",
 20+	"settings",
 21+	"logs",
 22+	"analytics",
 23+	"chat",
 24+	"pico+",
 25+}
 26+
 27+type UIVx struct {
 28+	shared *common.SharedModel
 29+	vx     *vaxis.Vaxis
 30+
 31+	page string
 32+	quit bool
 33+	menu list.List
 34+}
 35+
 36+type Navigate struct {
 37+	To string
 38+}
 39+
 40+type Quit struct{}
 41+
 42+func (ui *UIVx) menuPage(win vaxis.Window, ev vaxis.Event) {
 43+	switch msg := ev.(type) {
 44+	case vaxis.Key:
 45+		switch msg.String() {
 46+		case "Ctrl+c", "q", "Escape":
 47+			ui.quit = true
 48+		case "Down", "j":
 49+			ui.menu.Down()
 50+		case "Up", "k":
 51+			ui.menu.Up()
 52+		case "Enter":
 53+			ui.page = menuChoices[ui.menu.Index()]
 54+		}
 55+	}
 56+	ui.menu.Draw(win)
 57+}
 58+
 59+func (ui *UIVx) keysPage(win vaxis.Window, ev vaxis.Event) {
 60+	switch msg := ev.(type) {
 61+	case vaxis.Key:
 62+		switch msg.String() {
 63+		case "Ctrl+c":
 64+			ui.quit = true
 65+		case "q", "Escape":
 66+			ui.page = "menu"
 67+		}
 68+	}
 69+	win.Print(vaxis.Segment{Text: "Hello, World!"})
 70+}
 71+
 72+func NewTui(opts vaxis.Options, shared *common.SharedModel) {
 73 	vx, err := vaxis.New(opts)
 74 	if err != nil {
 75 		panic(err)
 76 	}
 77 	defer vx.Close()
 78+
 79+	ui := UIVx{
 80+		shared: shared,
 81+		vx:     vx,
 82+
 83+		page: "menu",
 84+		menu: list.New(menuChoices),
 85+	}
 86+
 87 	for ev := range vx.Events() {
 88-		switch ev := ev.(type) {
 89-		case vaxis.Key:
 90-			switch ev.String() {
 91-			case "Ctrl+c":
 92-				return
 93-			case "q":
 94-				return
 95-			case "Escape":
 96-				return
 97-			}
 98-		}
 99 		win := vx.Window()
100 		win.Clear()
101-		win.Print(vaxis.Segment{Text: "Hello, World!"})
102+
103+		// header
104+		win.Print(vaxis.Segment{
105+			Text: fmt.Sprintf("pico.sh • %s", ui.page),
106+		})
107+
108+		// page window
109+		width, height := win.Size()
110+		pageWin := win.New(0, 2, width, height-2)
111+
112+		switch ui.page {
113+		case "menu":
114+			ui.menuPage(pageWin, ev)
115+		case "pubkeys":
116+			ui.keysPage(pageWin, ev)
117+		}
118+
119+		if ui.quit {
120+			return
121+		}
122+
123 		vx.Render()
124 	}
125 }