Eric Bower
·
2026-10-20
1package tui
2
3import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "math"
8 "strings"
9
10 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
11 "github.com/picosh/pico/pkg/db"
12 "github.com/picosh/pico/pkg/pssh"
13 "github.com/picosh/pico/pkg/shared"
14 "go.rockorager.dev/vaxis"
15 "go.rockorager.dev/vaxis/vxfw"
16 "go.rockorager.dev/vaxis/vxfw/richtext"
17)
18
19const (
20 adminPrefix = "admin__"
21 HOME = "dash"
22)
23
24type SharedModel struct {
25 Logger *slog.Logger
26 Session *pssh.SSHServerConnSession
27 TunsSecret string
28 Dbpool db.DB
29 PgsDB pgsdb.PgsDB
30 User *db.User
31 InvitedBy string
32 PlusFeatureFlag *db.FeatureFlag
33 BouncerFeatureFlag *db.FeatureFlag
34 Impersonator string
35 App *vxfw.App
36}
37
38type Navigate struct{ To string }
39type PageIn struct{}
40type PageOut struct{}
41
42var fuschia = vaxis.HexColor(0xEE6FF8)
43var cream = vaxis.HexColor(0xFFFDF5)
44var green = vaxis.HexColor(0x04B575)
45var grey = vaxis.HexColor(0x5C5C5C)
46var red = vaxis.HexColor(0xED567A)
47
48// var white = vaxis.HexColor(0xFFFFFF).
49var oj = vaxis.HexColor(0xFFCA80)
50var purp = vaxis.HexColor(0xBD93F9)
51var black = vaxis.HexColor(0x282A36)
52
53func createDrawCtx(ctx vxfw.DrawContext, h int) vxfw.DrawContext {
54 // setting some sane defaults to prevent exceptions
55 var height uint16 = 1
56 switch {
57 case h == -1:
58 if ctx.Max.Height < math.MaxUint16 {
59 height = ctx.Max.Height
60 }
61 case h >= 1:
62 height = uint16(h)
63 }
64
65 var width uint16 = 80
66 if ctx.Max.Width < math.MaxUint16 {
67 width = ctx.Max.Width
68 }
69
70 return vxfw.DrawContext{
71 Characters: ctx.Characters,
72 Max: vxfw.Size{
73 Width: width,
74 Height: height,
75 },
76 }
77}
78
79type App struct {
80 shared *SharedModel
81 pages map[string]vxfw.Widget
82 page string
83}
84
85func (app *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
86 switch msg := ev.(type) {
87 case vaxis.Key:
88 if msg.Matches('c', vaxis.ModCtrl) {
89 return vxfw.QuitCmd{}, nil
90 }
91 if msg.Matches(vaxis.KeyEsc) {
92 if app.page == "signup" || app.page == HOME {
93 return nil, nil
94 }
95 app.shared.App.PostEvent(Navigate{To: HOME})
96 }
97 }
98 return nil, nil
99}
100
101type WidgetFooter interface {
102 Footer() []Shortcut
103}
104
105func (app *App) GetCurPage() vxfw.Widget {
106 return app.pages[app.page]
107}
108
109func (app *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
110 switch msg := ev.(type) {
111 case vxfw.Init:
112 page := HOME
113 // no user? kick them to the create account page
114 if app.shared.User == nil {
115 page = "signup"
116 }
117 app.shared.App.PostEvent(Navigate{To: page})
118 return nil, nil
119 case Navigate:
120 cmds := []vxfw.Command{}
121 cur := app.GetCurPage()
122 if cur != nil {
123 // send event to page notifying that we are leaving
124 cmd, _ := cur.(vxfw.EventHandler).HandleEvent(PageOut{}, vxfw.TargetPhase)
125 if cmd != nil {
126 cmds = append(cmds, cmd)
127 }
128 }
129
130 // switch the page
131 app.page = msg.To
132
133 next := app.GetCurPage()
134 if next != nil {
135 // send event to page notifying that we are entering
136 cmd, _ := next.(vxfw.EventHandler).HandleEvent(PageIn{}, vxfw.TargetPhase)
137 if cmd != nil {
138 cmds = append(cmds, cmd)
139 }
140 }
141
142 cmds = append(
143 cmds,
144 vxfw.RedrawCmd{},
145 )
146 return vxfw.BatchCmd(cmds), nil
147 }
148 return nil, nil
149}
150
151func (app *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
152 w := ctx.Max.Width
153 h := ctx.Max.Height
154 root := vxfw.NewSurface(w, ctx.Min.Height, app)
155
156 ah := 1
157 header := NewHeaderWdt(app.shared, app.page)
158 headerSurf, _ := header.Draw(vxfw.DrawContext{
159 Max: vxfw.Size{Width: w, Height: 2},
160 Characters: ctx.Characters,
161 })
162 root.AddChild(1, ah, headerSurf)
163 ah += int(headerSurf.Size.Height)
164
165 cur := app.GetCurPage()
166 if cur != nil {
167 pageCtx := vxfw.DrawContext{
168 Characters: ctx.Characters,
169 Max: vxfw.Size{Width: ctx.Max.Width - 1, Height: h - 2 - uint16(ah)},
170 }
171 surface, _ := app.GetCurPage().Draw(pageCtx)
172 root.AddChild(1, ah, surface)
173 }
174
175 wdgt, ok := cur.(WidgetFooter)
176 segs := []Shortcut{
177 {Shortcut: "^c", Text: "quit"},
178 {Shortcut: "esc", Text: "prev page"},
179 }
180 if ok {
181 segs = append(segs, wdgt.Footer()...)
182 }
183 footer := NewFooterWdt(app.shared, segs)
184 footerSurf, _ := footer.Draw(vxfw.DrawContext{
185 Max: vxfw.Size{Width: w, Height: 2},
186 Characters: ctx.Characters,
187 })
188 root.AddChild(1, int(ctx.Max.Height)-2, footerSurf)
189
190 return root, nil
191}
192
193type HeaderWdgt struct {
194 shared *SharedModel
195
196 page string
197}
198
199func NewHeaderWdt(shrd *SharedModel, page string) *HeaderWdgt {
200 return &HeaderWdgt{
201 shared: shrd,
202 page: page,
203 }
204}
205
206func (m *HeaderWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
207 return nil, nil
208}
209
210func (m *HeaderWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
211 logoTxt := "pico.sh"
212 ff := m.shared.PlusFeatureFlag
213 if ff != nil && ff.IsValid() {
214 logoTxt = "pico+"
215 }
216
217 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
218 // header
219 wdgt := richtext.New([]vaxis.Segment{
220 {Text: " " + logoTxt + " ", Style: vaxis.Style{Background: purp, Foreground: black}},
221 {Text: " • " + m.page, Style: vaxis.Style{Foreground: green}},
222 })
223 surf, _ := wdgt.Draw(ctx)
224 root.AddChild(0, 0, surf)
225
226 if m.shared.User != nil {
227 user := richtext.New([]vaxis.Segment{
228 {Text: "~" + m.shared.User.Name, Style: vaxis.Style{Foreground: cream}},
229 })
230 surf, _ = user.Draw(ctx)
231 root.AddChild(int(ctx.Max.Width)-int(surf.Size.Width)-1, 0, surf)
232 }
233
234 return root, nil
235}
236
237type Shortcut struct {
238 Text string
239 Shortcut string
240}
241
242type FooterWdgt struct {
243 shared *SharedModel
244
245 cmds []Shortcut
246}
247
248func NewFooterWdt(shrd *SharedModel, cmds []Shortcut) *FooterWdgt {
249 return &FooterWdgt{
250 shared: shrd,
251 cmds: cmds,
252 }
253}
254
255func (m *FooterWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
256 segs := []vaxis.Segment{}
257 for idx, shortcut := range m.cmds {
258 segs = append(
259 segs,
260 vaxis.Segment{Text: shortcut.Shortcut, Style: vaxis.Style{Foreground: fuschia}},
261 vaxis.Segment{Text: " " + shortcut.Text},
262 )
263 if idx < len(m.cmds)-1 {
264 segs = append(segs, vaxis.Segment{Text: " • "})
265 }
266 }
267 wdgt := richtext.New(segs)
268 return wdgt.Draw(ctx)
269}
270
271func initData(shrd *SharedModel) error {
272 user, err := FindUser(shrd)
273 if err != nil {
274 return err
275 }
276 shrd.User = user
277
278 ff, _ := FindFeatureFlag(shrd, "plus")
279 shrd.PlusFeatureFlag = ff
280
281 bff, _ := FindFeatureFlag(shrd, "bouncer")
282 shrd.BouncerFeatureFlag = bff
283
284 if shrd.User != nil {
285 invitedBy, _ := shrd.Dbpool.FindWhoInvitedUser(user.ID)
286 shrd.InvitedBy = invitedBy
287 }
288 return nil
289}
290
291func FindUser(shrd *SharedModel) (*db.User, error) {
292 logger := shrd.Logger
293 var user *db.User
294 usr := shrd.Session.User()
295
296 if shrd.Session.PublicKey() == nil {
297 return nil, fmt.Errorf("unable to find public key")
298 }
299
300 key := shared.KeyForKeyText(shrd.Session.PublicKey())
301
302 user, err := shrd.Dbpool.FindUserByKey(usr, key)
303 if err != nil {
304 logger.Error("no user found for public key", "err", err.Error())
305 // we only want to throw an error for specific cases
306 if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
307 return nil, err
308 }
309 // no user and not error indicates we need to create an account
310 return nil, nil
311 }
312
313 origUserName := user.Name
314
315 // impersonation
316 if strings.HasPrefix(usr, adminPrefix) {
317 hasFeature := db.HasFeature(shrd.Dbpool, user.ID, "admin")
318 if hasFeature {
319 impersonate := strings.TrimPrefix(usr, adminPrefix)
320 user, err = shrd.Dbpool.FindUserByName(impersonate)
321 if err == nil {
322 shrd.Impersonator = origUserName
323 }
324 }
325 }
326
327 return user, nil
328}
329
330func FindFeatureFlag(shrd *SharedModel, name string) (*db.FeatureFlag, error) {
331 if shrd.User == nil {
332 return nil, nil
333 }
334
335 ff, err := shrd.Dbpool.FindFeature(shrd.User.ID, name)
336 if err != nil {
337 return nil, err
338 }
339
340 return ff, nil
341}
342
343func NewTui(opts vaxis.Options, shrd *SharedModel) error {
344 err := initData(shrd)
345 if err != nil {
346 return err
347 }
348
349 app, err := vxfw.NewApp(opts)
350 if err != nil {
351 return err
352 }
353
354 shrd.App = app
355 pages := map[string]vxfw.Widget{
356 HOME: NewMenuPage(shrd),
357 "pubkeys": NewPubkeysPage(shrd),
358 "add-pubkey": NewAddPubkeyPage(shrd),
359 "invite": NewAddInvitePage(shrd),
360 "tokens": NewTokensPage(shrd),
361 "add-token": NewAddTokenPage(shrd),
362 "signup": NewSignupPage(shrd),
363 "pico+": NewPlusPage(shrd),
364 "logs": NewLogsPage(shrd),
365 "analytics": NewAnalyticsPage(shrd),
366 "chat": NewChatPage(shrd),
367 "tuns": NewTunsPage(shrd),
368 "access-logs": NewAccessLogsPage(shrd),
369 "pages": NewPagesPage(shrd),
370 }
371 root := &App{
372 shared: shrd,
373 pages: pages,
374 }
375
376 return app.Run(root)
377}