repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
50895e2
parent
1941f3e
author
Eric Bower
date
2024-05-16 19:35:12 +0000 UTC
refactor(tui): reorganize into pages
21 files changed,  +1084, -1339
M go.mod
M go.sum
M go.mod
+1, -2
 1@@ -20,13 +20,11 @@ require (
 2 	github.com/charmbracelet/wish v1.4.0
 3 	github.com/google/go-cmp v0.6.0
 4 	github.com/gorilla/feeds v1.1.2
 5-	github.com/kr/pty v1.1.8
 6 	github.com/lib/pq v1.10.9
 7 	github.com/microcosm-cc/bluemonday v1.0.26
 8 	github.com/minio/minio-go/v7 v7.0.70
 9 	github.com/mmcdole/gofeed v1.3.0
10 	github.com/muesli/reflow v0.3.0
11-	github.com/muesli/termenv v0.15.2
12 	github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
13 	github.com/picosh/pobj v0.0.0-20240417140600-2071618b61c5
14 	github.com/picosh/ptun v0.0.0-20240417140706-811cc2b70d9a
15@@ -108,6 +106,7 @@ require (
16 	github.com/modern-go/reflect2 v1.0.2 // indirect
17 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
18 	github.com/muesli/cancelreader v0.2.2 // indirect
19+	github.com/muesli/termenv v0.15.2 // indirect
20 	github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e // indirect
21 	github.com/olekukonko/tablewriter v0.0.5 // indirect
22 	github.com/philhofer/fwd v1.1.2 // indirect
M go.sum
+0, -3
 1@@ -55,7 +55,6 @@ github.com/charmbracelet/x/errors v0.0.0-20240507171223-71e9351b56e7 h1:kYsUiL7Z
 2 github.com/charmbracelet/x/errors v0.0.0-20240507171223-71e9351b56e7/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 3 github.com/charmbracelet/x/exp/term v0.0.0-20240507171223-71e9351b56e7 h1:ATeHxDzJnkCWPCNhTPZUMxyD7AE94ATJDKHN3wZNRUY=
 4 github.com/charmbracelet/x/exp/term v0.0.0-20240507171223-71e9351b56e7/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
 5-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 6 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
 7 github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 9@@ -156,8 +155,6 @@ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
10 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
11 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
12 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
13-github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
14-github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
15 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
D tui/account/create.go
+0, -277
  1@@ -1,277 +0,0 @@
  2-package account
  3-
  4-import (
  5-	"errors"
  6-	"fmt"
  7-	"strings"
  8-
  9-	input "github.com/charmbracelet/bubbles/textinput"
 10-	tea "github.com/charmbracelet/bubbletea"
 11-	"github.com/charmbracelet/ssh"
 12-	"github.com/picosh/pico/db"
 13-	"github.com/picosh/pico/shared"
 14-	"github.com/picosh/pico/tui/common"
 15-)
 16-
 17-type state int
 18-
 19-const (
 20-	ready state = iota
 21-	submitting
 22-)
 23-
 24-// index specifies the UI element that's in focus.
 25-type index int
 26-
 27-const (
 28-	textInput index = iota
 29-	okButton
 30-	cancelButton
 31-)
 32-
 33-type CreateAccountMsg *db.User
 34-
 35-// NameTakenMsg is sent when the requested username has already been taken.
 36-type NameTakenMsg struct{}
 37-
 38-// NameInvalidMsg is sent when the requested username has failed validation.
 39-type NameInvalidMsg struct{}
 40-
 41-type errMsg struct{ err error }
 42-
 43-func (e errMsg) Error() string { return e.err.Error() }
 44-
 45-var deny = strings.Join(db.DenyList, ", ")
 46-var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
 47-
 48-// Model holds the state of the username UI.
 49-type CreateModel struct {
 50-	Done bool // true when it's time to exit this view
 51-	Quit bool // true when the user wants to quit the whole program
 52-
 53-	dbpool    db.DB
 54-	publicKey ssh.PublicKey
 55-	styles    common.Styles
 56-	state     state
 57-	newName   string
 58-	index     index
 59-	errMsg    string
 60-	input     input.Model
 61-}
 62-
 63-// updateFocus updates the focused states in the model based on the current
 64-// focus index.
 65-func (m *CreateModel) updateFocus() {
 66-	if m.index == textInput && !m.input.Focused() {
 67-		m.input.Focus()
 68-		m.input.Prompt = m.styles.FocusedPrompt.String()
 69-	} else if m.index != textInput && m.input.Focused() {
 70-		m.input.Blur()
 71-		m.input.Prompt = m.styles.Prompt.String()
 72-	}
 73-}
 74-
 75-// Move the focus index one unit forward.
 76-func (m *CreateModel) indexForward() {
 77-	m.index++
 78-	if m.index > cancelButton {
 79-		m.index = textInput
 80-	}
 81-
 82-	m.updateFocus()
 83-}
 84-
 85-// Move the focus index one unit backwards.
 86-func (m *CreateModel) indexBackward() {
 87-	m.index--
 88-	if m.index < textInput {
 89-		m.index = cancelButton
 90-	}
 91-
 92-	m.updateFocus()
 93-}
 94-
 95-// NewModel returns a new username model in its initial state.
 96-func NewCreateModel(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) CreateModel {
 97-	im := input.New()
 98-	im.Cursor.Style = styles.Cursor
 99-	im.Placeholder = "enter username"
100-	im.Prompt = styles.FocusedPrompt.String()
101-	im.CharLimit = 50
102-	im.Focus()
103-
104-	return CreateModel{
105-		Done:      false,
106-		Quit:      false,
107-		dbpool:    dbpool,
108-		styles:    styles,
109-		state:     ready,
110-		newName:   "",
111-		index:     textInput,
112-		errMsg:    "",
113-		input:     im,
114-		publicKey: publicKey,
115-	}
116-}
117-
118-// Init is the Bubble Tea initialization function.
119-func Init(styles common.Styles, dbpool db.DB, publicKey ssh.PublicKey) func() (CreateModel, tea.Cmd) {
120-	return func() (CreateModel, tea.Cmd) {
121-		m := NewCreateModel(styles, dbpool, publicKey)
122-		return m, InitialCmd()
123-	}
124-}
125-
126-// InitialCmd returns the initial command.
127-func InitialCmd() tea.Cmd {
128-	return input.Blink
129-}
130-
131-// Update is the Bubble Tea update loop.
132-func Update(msg tea.Msg, m CreateModel) (CreateModel, tea.Cmd) {
133-	switch msg := msg.(type) {
134-	case tea.KeyMsg:
135-		switch msg.Type {
136-		case tea.KeyCtrlC, tea.KeyEscape:
137-			m.Quit = true
138-			return m, nil
139-
140-		default:
141-			// Ignore keys if we're submitting
142-			if m.state == submitting {
143-				return m, nil
144-			}
145-
146-			switch msg.String() {
147-			case "tab":
148-				m.indexForward()
149-			case "shift+tab":
150-				m.indexBackward()
151-			case "l", "k", "right":
152-				if m.index != textInput {
153-					m.indexForward()
154-				}
155-			case "h", "j", "left":
156-				if m.index != textInput {
157-					m.indexBackward()
158-				}
159-			case "up", "down":
160-				if m.index == textInput {
161-					m.indexForward()
162-				} else {
163-					m.index = textInput
164-					m.updateFocus()
165-				}
166-			case "enter":
167-				switch m.index {
168-				case textInput:
169-					fallthrough
170-				case okButton: // Submit the form
171-					m.state = submitting
172-					m.errMsg = ""
173-					m.newName = strings.TrimSpace(m.input.Value())
174-
175-					return m, createAccount(m)
176-				case cancelButton: // Exit
177-					m.Quit = true
178-					return m, nil
179-				}
180-			}
181-
182-			// Pass messages through to the input element if that's the element
183-			// in focus
184-			if m.index == textInput {
185-				var cmd tea.Cmd
186-				m.input, cmd = m.input.Update(msg)
187-
188-				return m, cmd
189-			}
190-
191-			return m, nil
192-		}
193-
194-	case NameTakenMsg:
195-		m.state = ready
196-		m.errMsg = m.styles.Subtle.Render("Sorry, ") +
197-			m.styles.Error.Render(m.newName) +
198-			m.styles.Subtle.Render(" is taken.")
199-
200-		return m, nil
201-
202-	case NameInvalidMsg:
203-		m.state = ready
204-		head := m.styles.Error.Render("Invalid name. ")
205-		body := m.styles.Subtle.Render(helpMsg)
206-		m.errMsg = m.styles.Wrap.Render(head + body)
207-
208-		return m, nil
209-
210-	case errMsg:
211-		m.state = ready
212-		head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
213-		body := m.styles.Subtle.Render(msg.Error())
214-		m.errMsg = m.styles.Wrap.Render(head + body)
215-
216-		return m, nil
217-
218-	default:
219-		var cmd tea.Cmd
220-		m.input, cmd = m.input.Update(msg) // Do we still need this?
221-
222-		return m, cmd
223-	}
224-}
225-
226-// View renders current view from the model.
227-func View(m CreateModel) string {
228-	intro := "To create an account, enter a username.\n\n"
229-	intro += "After that, go to https://pico.sh/getting-started#next-steps"
230-	s := fmt.Sprintf("%s\n\n%s\n\n", "hacker labs", intro)
231-	s += fmt.Sprintf("Public Key: %s\n\n", shared.KeyForSha256(m.publicKey))
232-	s += m.input.View() + "\n\n"
233-
234-	if m.state == submitting {
235-		s += spinnerView(m)
236-	} else {
237-		s += common.OKButtonView(m.styles, m.index == 1, true)
238-		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
239-		if m.errMsg != "" {
240-			s += "\n\n" + m.errMsg
241-		}
242-	}
243-	s += fmt.Sprintf("\n\n%s\n", helpMsg)
244-
245-	return s
246-}
247-
248-func spinnerView(m CreateModel) string {
249-	return "Creating account..."
250-}
251-
252-func createAccount(m CreateModel) tea.Cmd {
253-	return func() tea.Msg {
254-		if m.newName == "" {
255-			return NameInvalidMsg{}
256-		}
257-
258-		key, err := shared.KeyForKeyText(m.publicKey)
259-		if err != nil {
260-			return errMsg{err}
261-		}
262-
263-		user, err := m.dbpool.RegisterUser(m.newName, key, "")
264-		if err != nil {
265-			if errors.Is(err, db.ErrNameTaken) {
266-				return NameTakenMsg{}
267-			} else if errors.Is(err, db.ErrNameInvalid) {
268-				return NameInvalidMsg{}
269-			} else if errors.Is(err, db.ErrNameDenied) {
270-				return NameInvalidMsg{}
271-			} else {
272-				return errMsg{err}
273-			}
274-		}
275-
276-		return CreateAccountMsg(user)
277-	}
278-}
D tui/cms.go
+0, -460
  1@@ -1,460 +0,0 @@
  2-package tui
  3-
  4-import (
  5-	"errors"
  6-	"io"
  7-
  8-	tea "github.com/charmbracelet/bubbletea"
  9-	"github.com/charmbracelet/ssh"
 10-	"github.com/charmbracelet/wish"
 11-	bm "github.com/charmbracelet/wish/bubbletea"
 12-	"github.com/muesli/reflow/indent"
 13-	"github.com/muesli/reflow/wordwrap"
 14-	"github.com/muesli/reflow/wrap"
 15-	"github.com/picosh/pico/db"
 16-	"github.com/picosh/pico/db/postgres"
 17-	"github.com/picosh/pico/shared"
 18-	"github.com/picosh/pico/tui/account"
 19-	"github.com/picosh/pico/tui/common"
 20-	"github.com/picosh/pico/tui/info"
 21-	"github.com/picosh/pico/tui/keys"
 22-	"github.com/picosh/pico/tui/notifications"
 23-	"github.com/picosh/pico/tui/plus"
 24-	"github.com/picosh/pico/tui/tokens"
 25-)
 26-
 27-type status int
 28-
 29-const (
 30-	statusInit status = iota
 31-	statusReady
 32-	statusNoAccount
 33-	statusBrowsingKeys
 34-	statusBrowsingTokens
 35-	statusBrowsingNotifications
 36-	statusBrowsingPlus
 37-	statusChat
 38-	statusQuitting
 39-)
 40-
 41-func (s status) String() string {
 42-	return [...]string{
 43-		"initializing",
 44-		"ready",
 45-		"browsing keys",
 46-		"quitting",
 47-		"error",
 48-	}[s]
 49-}
 50-
 51-// menuChoice represents a chosen menu item.
 52-type menuChoice int
 53-
 54-// menu choices.
 55-const (
 56-	keysChoice menuChoice = iota
 57-	tokensChoice
 58-	notificationsChoice
 59-	plusChoice
 60-	chatChoice
 61-	exitChoice
 62-	unsetChoice // set when no choice has been made
 63-)
 64-
 65-// menu text corresponding to menu choices. these are presented to the user.
 66-var menuChoices = map[menuChoice]string{
 67-	keysChoice:          "Manage keys",
 68-	tokensChoice:        "Manage tokens",
 69-	notificationsChoice: "Notifications",
 70-	plusChoice:          "Pico+",
 71-	chatChoice:          "Chat",
 72-	exitChoice:          "Exit",
 73-}
 74-
 75-type GotDBMsg db.DB
 76-
 77-func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
 78-	return func(sesh ssh.Session) (tea.Model, []tea.ProgramOption) {
 79-		logger := cfg.Logger
 80-
 81-		_, _, active := sesh.Pty()
 82-		if !active {
 83-			logger.Info("no active terminal, skipping")
 84-			return nil, nil
 85-		}
 86-
 87-		sshUser := sesh.User()
 88-		dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 89-		renderer := bm.MakeRenderer(sesh)
 90-		styles := common.DefaultStyles(renderer)
 91-
 92-		m := model{
 93-			session:    sesh,
 94-			cfg:        cfg,
 95-			publicKey:  sesh.PublicKey(),
 96-			dbpool:     dbpool,
 97-			sshUser:    sshUser,
 98-			status:     statusInit,
 99-			menuChoice: unsetChoice,
100-			styles:     styles,
101-			terminalSize: tea.WindowSizeMsg{
102-				Width:  80,
103-				Height: 24,
104-			},
105-		}
106-
107-		user, err := m.findUser()
108-		if err != nil {
109-			wish.Errorln(sesh, err)
110-			return nil, nil
111-		}
112-		m.user = user
113-
114-		ff, _ := m.findPlusFeatureFlag()
115-		m.plusFeatureFlag = ff
116-
117-		opts := bm.MakeOptions(sesh)
118-		opts = append(opts, tea.WithAltScreen())
119-		return m, opts
120-	}
121-}
122-
123-// Just a generic tea.Model to demo terminal information of ssh.
124-type model struct {
125-	cfg             *shared.ConfigSite
126-	publicKey       ssh.PublicKey
127-	dbpool          db.DB
128-	user            *db.User
129-	plusFeatureFlag *db.FeatureFlag
130-	err             error
131-	sshUser         string
132-	status          status
133-	menuIndex       int
134-	menuChoice      menuChoice
135-	styles          common.Styles
136-	info            info.Model
137-	keys            keys.Model
138-	tokens          tokens.Model
139-	plus            plus.Model
140-	notifications   notifications.Model
141-	createAccount   account.CreateModel
142-	terminalSize    tea.WindowSizeMsg
143-	session         ssh.Session
144-}
145-
146-func (m model) Init() tea.Cmd {
147-	return nil
148-}
149-
150-func (m model) findUser() (*db.User, error) {
151-	logger := m.cfg.Logger
152-	var user *db.User
153-
154-	if m.sshUser == "new" {
155-		logger.Info("user requesting to register account")
156-		return nil, nil
157-	}
158-
159-	key, err := shared.KeyForKeyText(m.publicKey)
160-	if err != nil {
161-		return nil, err
162-	}
163-
164-	user, err = m.dbpool.FindUserForKey(m.sshUser, key)
165-	if err != nil {
166-		logger.Error("no user found for public key", "err", err.Error())
167-		// we only want to throw an error for specific cases
168-		if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
169-			return nil, err
170-		}
171-		// no user and not error indicates we need to create an account
172-		return nil, nil
173-	}
174-
175-	return user, nil
176-}
177-
178-func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
179-	if m.user == nil {
180-		return nil, nil
181-	}
182-
183-	ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "plus")
184-	if err != nil {
185-		return nil, err
186-	}
187-
188-	return ff, nil
189-}
190-
191-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192-	var (
193-		cmds []tea.Cmd
194-	)
195-
196-	switch msg := msg.(type) {
197-	case tea.WindowSizeMsg:
198-		m.terminalSize = msg
199-		var cmd tea.Cmd
200-		m.plus, cmd = plus.Update(msg, m.plus, m.terminalSize)
201-		cmds = append(cmds, cmd)
202-		m.notifications, cmd = notifications.Update(msg, m.notifications, m.terminalSize)
203-		cmds = append(cmds, cmd)
204-	case tea.KeyMsg:
205-		switch msg.Type {
206-		case tea.KeyCtrlC:
207-			m.dbpool.Close()
208-			return m, tea.Quit
209-		}
210-
211-		if m.status == statusReady { // Process keys for the menu
212-			switch msg.String() {
213-			// Quit
214-			case "q", "esc":
215-				m.status = statusQuitting
216-				m.dbpool.Close()
217-				return m, tea.Quit
218-
219-			// Prev menu item
220-			case "up", "k":
221-				m.menuIndex--
222-				if m.menuIndex < 0 {
223-					m.menuIndex = len(menuChoices) - 1
224-				}
225-
226-			// Select menu item
227-			case "enter":
228-				m.menuChoice = menuChoice(m.menuIndex)
229-
230-			// Next menu item
231-			case "down", "j":
232-				m.menuIndex++
233-				if m.menuIndex >= len(menuChoices) {
234-					m.menuIndex = 0
235-				}
236-			}
237-		}
238-	case account.CreateAccountMsg:
239-		m.status = statusReady
240-		m.info.User = msg
241-		m.user = msg
242-		m.info = info.NewModel(m.styles, m.user, m.plusFeatureFlag)
243-		m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
244-		m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
245-		m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey)
246-		m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
247-		m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
248-	}
249-
250-	switch m.status {
251-	case statusInit:
252-		m.info = info.NewModel(m.styles, m.user, m.plusFeatureFlag)
253-		m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
254-		m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
255-		m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey)
256-		m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
257-		m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
258-		// no user found? go to create account screen
259-		if m.user == nil {
260-			m.status = statusNoAccount
261-		} else {
262-			m.status = statusReady
263-		}
264-	}
265-
266-	var cmd tea.Cmd
267-	m, cmd = updateChildren(msg, m)
268-	if cmd != nil {
269-		cmds = append(cmds, cmd)
270-	}
271-
272-	return m, tea.Batch(cmds...)
273-}
274-
275-func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
276-	var cmd tea.Cmd
277-
278-	switch m.status {
279-	case statusBrowsingKeys:
280-		newModel, newCmd := m.keys.Update(msg)
281-		keysModel, ok := newModel.(keys.Model)
282-		if !ok {
283-			panic("could not perform assertion on keys model")
284-		}
285-		m.keys = keysModel
286-		cmd = newCmd
287-
288-		if m.keys.Exit {
289-			m.keys = keys.NewModel(m.styles, m.cfg.Logger, m.dbpool, m.user)
290-			m.status = statusReady
291-		} else if m.keys.Quit {
292-			m.status = statusQuitting
293-			return m, tea.Quit
294-		}
295-	case statusBrowsingTokens:
296-		newModel, newCmd := m.tokens.Update(msg)
297-		tokensModel, ok := newModel.(tokens.Model)
298-		if !ok {
299-			panic("could not perform assertion on posts model")
300-		}
301-		m.tokens = tokensModel
302-		cmd = newCmd
303-
304-		if m.tokens.Exit {
305-			m.tokens = tokens.NewModel(m.styles, m.dbpool, m.user)
306-			m.status = statusReady
307-		} else if m.tokens.Quit {
308-			m.status = statusQuitting
309-			return m, tea.Quit
310-		}
311-	case statusBrowsingPlus:
312-		m.plus, cmd = plus.Update(msg, m.plus, m.terminalSize)
313-		if m.plus.Done {
314-			m.plus = plus.NewModel(m.styles, m.user, m.session, m.terminalSize)
315-			m.status = statusReady
316-		} else if m.plus.Quit {
317-			m.status = statusQuitting
318-			return m, tea.Quit
319-		}
320-	case statusBrowsingNotifications:
321-		m.notifications, cmd = notifications.Update(msg, m.notifications, m.terminalSize)
322-		if m.notifications.Done {
323-			m.notifications = notifications.NewModel(m.styles, m.dbpool, m.user, m.terminalSize)
324-			m.status = statusReady
325-		} else if m.notifications.Quit {
326-			m.status = statusQuitting
327-			return m, tea.Quit
328-		}
329-	case statusNoAccount:
330-		m.createAccount, cmd = account.Update(msg, m.createAccount)
331-		if m.createAccount.Done {
332-			m.createAccount = account.NewCreateModel(m.styles, m.dbpool, m.publicKey) // reset the state
333-			m.status = statusReady
334-		} else if m.createAccount.Quit {
335-			m.status = statusQuitting
336-			return m, tea.Quit
337-		}
338-	}
339-
340-	// Handle the menu
341-	switch m.menuChoice {
342-	case keysChoice:
343-		m.status = statusBrowsingKeys
344-		m.menuChoice = unsetChoice
345-		cmd = keys.LoadKeys(m.keys)
346-	case tokensChoice:
347-		m.status = statusBrowsingTokens
348-		m.menuChoice = unsetChoice
349-		cmd = tokens.LoadKeys(m.tokens)
350-	case plusChoice:
351-		m.status = statusBrowsingPlus
352-		m.menuChoice = unsetChoice
353-	case notificationsChoice:
354-		m.status = statusBrowsingNotifications
355-		m.menuChoice = unsetChoice
356-	case chatChoice:
357-		m.status = statusChat
358-		m.menuChoice = unsetChoice
359-		cmd = m.loadChat()
360-	case exitChoice:
361-		m.status = statusQuitting
362-		m.dbpool.Close()
363-		cmd = tea.Quit
364-	}
365-
366-	return m, cmd
367-}
368-
369-type SenpaiCmd struct {
370-	user    *db.User
371-	session ssh.Session
372-	dbpool  db.DB
373-}
374-
375-func (m *SenpaiCmd) Run() error {
376-	pass, err := m.dbpool.UpsertToken(m.user.ID, "pico-chat")
377-	if err != nil {
378-		return err
379-	}
380-	app, err := shared.NewSenpaiApp(m.session, m.user.Name, pass)
381-	if err != nil {
382-		return err
383-	}
384-	app.Run()
385-	app.Close()
386-	return nil
387-}
388-
389-func (m *SenpaiCmd) SetStdin(io.Reader)  {}
390-func (m *SenpaiCmd) SetStdout(io.Writer) {}
391-func (m *SenpaiCmd) SetStderr(io.Writer) {}
392-
393-func (m model) loadChat() tea.Cmd {
394-	sp := &SenpaiCmd{
395-		session: m.session,
396-		dbpool:  m.dbpool,
397-		user:    m.user,
398-	}
399-	return tea.Exec(sp, func(err error) tea.Msg {
400-		return tea.Quit()
401-	})
402-}
403-
404-func (m model) menuView() string {
405-	var s string
406-	for i := 0; i < len(menuChoices); i++ {
407-		e := "  "
408-		menuItem := menuChoices[menuChoice(i)]
409-		if i == m.menuIndex {
410-			e = m.styles.SelectionMarker.String() +
411-				m.styles.SelectedMenuItem.Render(menuItem)
412-		} else {
413-			e += menuItem
414-		}
415-		if i < len(menuChoices)-1 {
416-			e += "\n"
417-		}
418-		s += e
419-	}
420-
421-	return s
422-}
423-
424-func footerView(m model) string {
425-	if m.err != nil {
426-		return m.errorView(m.err)
427-	}
428-	return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
429-}
430-
431-func (m model) errorView(err error) string {
432-	head := m.styles.Error.Render("Error: ")
433-	body := m.styles.Subtle.Render(err.Error())
434-	msg := m.styles.Wrap.Render(head + body)
435-	return "\n\n" + indent.String(msg, 2)
436-}
437-
438-func (m model) View() string {
439-	w := m.terminalSize.Width - m.styles.App.GetHorizontalFrameSize()
440-	s := m.styles.Logo.SetString("pico.sh").String() + "\n\n"
441-	switch m.status {
442-	case statusNoAccount:
443-		s += account.View(m.createAccount)
444-	case statusReady:
445-		s += m.info.View()
446-		s += "\n\n" + m.menuView()
447-		s += footerView(m)
448-	case statusBrowsingKeys:
449-		s += m.keys.View()
450-	case statusBrowsingTokens:
451-		s += m.tokens.View()
452-	case statusBrowsingPlus:
453-		s = plus.View(m.plus)
454-		return s
455-	case statusBrowsingNotifications:
456-		s = notifications.View(m.notifications)
457-		return s
458-	}
459-	str := wrap.String(wordwrap.String(s, w), w)
460-	return m.styles.App.Render(str)
461-}
A tui/common/model.go
+21, -0
 1@@ -0,0 +1,21 @@
 2+package common
 3+
 4+import (
 5+	"log/slog"
 6+
 7+	"github.com/charmbracelet/ssh"
 8+	"github.com/picosh/pico/db"
 9+	"github.com/picosh/pico/shared"
10+)
11+
12+type SharedModel struct {
13+	Logger          *slog.Logger
14+	Session         ssh.Session
15+	Cfg             *shared.ConfigSite
16+	Dbpool          db.DB
17+	User            *db.User
18+	PlusFeatureFlag *db.FeatureFlag
19+	Width           int
20+	Height          int
21+	Styles          Styles
22+}
M tui/common/styles.go
+1, -1
1@@ -58,7 +58,7 @@ func DefaultStyles(renderer *lipgloss.Renderer) Styles {
2 	s.Subtle = renderer.NewStyle().
3 		Foreground(Grey)
4 	s.Error = renderer.NewStyle().Foreground(Red)
5-	s.Prompt = renderer.NewStyle().MarginRight(1).SetString("•")
6+	s.Prompt = renderer.NewStyle().MarginRight(1).SetString(">")
7 	s.FocusedPrompt = s.Prompt.Copy().Foreground(Fuschia)
8 	s.Note = renderer.NewStyle().Foreground(Green)
9 	s.Delete = s.Error.Copy()
A tui/createaccount/create.go
+251, -0
  1@@ -0,0 +1,251 @@
  2+package createaccount
  3+
  4+import (
  5+	"errors"
  6+	"fmt"
  7+	"strings"
  8+
  9+	input "github.com/charmbracelet/bubbles/textinput"
 10+	tea "github.com/charmbracelet/bubbletea"
 11+	"github.com/picosh/pico/db"
 12+	"github.com/picosh/pico/shared"
 13+	"github.com/picosh/pico/tui/common"
 14+)
 15+
 16+type state int
 17+
 18+const (
 19+	ready state = iota
 20+	submitting
 21+)
 22+
 23+// index specifies the UI element that's in focus.
 24+type index int
 25+
 26+const (
 27+	textInput index = iota
 28+	okButton
 29+	cancelButton
 30+)
 31+
 32+type CreateAccountMsg *db.User
 33+
 34+// NameTakenMsg is sent when the requested username has already been taken.
 35+type NameTakenMsg struct{}
 36+
 37+// NameInvalidMsg is sent when the requested username has failed validation.
 38+type NameInvalidMsg struct{}
 39+
 40+type errMsg struct{ err error }
 41+
 42+func (e errMsg) Error() string { return e.err.Error() }
 43+
 44+var deny = strings.Join(db.DenyList, ", ")
 45+var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
 46+
 47+// Model holds the state of the username UI.
 48+type Model struct {
 49+	shared common.SharedModel
 50+
 51+	state   state
 52+	newName string
 53+	index   index
 54+	errMsg  string
 55+	input   input.Model
 56+}
 57+
 58+// NewModel returns a new username model in its initial state.
 59+func NewModel(shared common.SharedModel) Model {
 60+	im := input.New()
 61+	im.Cursor.Style = shared.Styles.Cursor
 62+	im.Placeholder = "enter username"
 63+	im.Prompt = shared.Styles.FocusedPrompt.String()
 64+	im.CharLimit = 50
 65+	im.Focus()
 66+
 67+	return Model{
 68+		shared:  shared,
 69+		state:   ready,
 70+		newName: "",
 71+		index:   textInput,
 72+		errMsg:  "",
 73+		input:   im,
 74+	}
 75+}
 76+
 77+// updateFocus updates the focused states in the model based on the current
 78+// focus index.
 79+func (m *Model) updateFocus() {
 80+	if m.index == textInput && !m.input.Focused() {
 81+		m.input.Focus()
 82+		m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
 83+	} else if m.index != textInput && m.input.Focused() {
 84+		m.input.Blur()
 85+		m.input.Prompt = m.shared.Styles.Prompt.String()
 86+	}
 87+}
 88+
 89+// Move the focus index one unit forward.
 90+func (m *Model) indexForward() {
 91+	m.index++
 92+	if m.index > cancelButton {
 93+		m.index = textInput
 94+	}
 95+
 96+	m.updateFocus()
 97+}
 98+
 99+// Move the focus index one unit backwards.
100+func (m *Model) indexBackward() {
101+	m.index--
102+	if m.index < textInput {
103+		m.index = cancelButton
104+	}
105+
106+	m.updateFocus()
107+}
108+
109+// Init is the Bubble Tea initialization function.
110+func (m Model) Init() tea.Cmd {
111+	return input.Blink
112+}
113+
114+// Update is the Bubble Tea update loop.
115+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116+	switch msg := msg.(type) {
117+	case tea.KeyMsg:
118+		// Ignore keys if we're submitting
119+		if m.state == submitting {
120+			return m, nil
121+		}
122+
123+		switch msg.String() {
124+		case "tab":
125+			m.indexForward()
126+		case "shift+tab":
127+			m.indexBackward()
128+		case "l", "k", "right":
129+			if m.index != textInput {
130+				m.indexForward()
131+			}
132+		case "h", "j", "left":
133+			if m.index != textInput {
134+				m.indexBackward()
135+			}
136+		case "up", "down":
137+			if m.index == textInput {
138+				m.indexForward()
139+			} else {
140+				m.index = textInput
141+				m.updateFocus()
142+			}
143+		case "enter":
144+			switch m.index {
145+			case textInput:
146+				fallthrough
147+			case okButton: // Submit the form
148+				m.state = submitting
149+				m.errMsg = ""
150+				m.newName = strings.TrimSpace(m.input.Value())
151+
152+				return m, m.createAccount()
153+			case cancelButton: // Exit
154+				return m, tea.Quit
155+			}
156+		}
157+
158+		// Pass messages through to the input element if that's the element
159+		// in focus
160+		if m.index == textInput {
161+			var cmd tea.Cmd
162+			m.input, cmd = m.input.Update(msg)
163+
164+			return m, cmd
165+		}
166+
167+		return m, nil
168+
169+	case NameTakenMsg:
170+		m.state = ready
171+		m.errMsg = m.shared.Styles.Subtle.Render("Sorry, ") +
172+			m.shared.Styles.Error.Render(m.newName) +
173+			m.shared.Styles.Subtle.Render(" is taken.")
174+
175+		return m, nil
176+
177+	case NameInvalidMsg:
178+		m.state = ready
179+		head := m.shared.Styles.Error.Render("Invalid name.")
180+		m.errMsg = m.shared.Styles.Wrap.Render(head)
181+
182+		return m, nil
183+
184+	case errMsg:
185+		m.state = ready
186+		head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
187+		body := m.shared.Styles.Subtle.Render(msg.Error())
188+		m.errMsg = m.shared.Styles.Wrap.Render(head + body)
189+
190+		return m, nil
191+
192+	default:
193+		var cmd tea.Cmd
194+		m.input, cmd = m.input.Update(msg) // Do we still need this?
195+
196+		return m, cmd
197+	}
198+}
199+
200+// View renders current view from the model.
201+func (m Model) View() string {
202+	intro := "To create an account, enter a username.\n\n"
203+	intro += "After that, go to https://pico.sh/getting-started#next-steps"
204+	s := fmt.Sprintf("%s\n\n%s\n\n", "hacker labs", intro)
205+	s += fmt.Sprintf("Public Key: %s\n\n", shared.KeyForSha256(m.shared.Session.PublicKey()))
206+	s += m.input.View() + "\n\n"
207+
208+	if m.state == submitting {
209+		s += m.spinnerView()
210+	} else {
211+		s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
212+		s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
213+		if m.errMsg != "" {
214+			s += "\n\n" + m.errMsg
215+		}
216+	}
217+	s += fmt.Sprintf("\n\n%s\n", helpMsg)
218+
219+	return s
220+}
221+
222+func (m Model) spinnerView() string {
223+	return "Creating account..."
224+}
225+
226+func (m *Model) createAccount() tea.Cmd {
227+	return func() tea.Msg {
228+		if m.newName == "" {
229+			return NameInvalidMsg{}
230+		}
231+
232+		key, err := shared.KeyForKeyText(m.shared.Session.PublicKey())
233+		if err != nil {
234+			return errMsg{err}
235+		}
236+
237+		user, err := m.shared.Dbpool.RegisterUser(m.newName, key, "")
238+		if err != nil {
239+			if errors.Is(err, db.ErrNameTaken) {
240+				return NameTakenMsg{}
241+			} else if errors.Is(err, db.ErrNameInvalid) {
242+				return NameInvalidMsg{}
243+			} else if errors.Is(err, db.ErrNameDenied) {
244+				return NameInvalidMsg{}
245+			} else {
246+				return errMsg{err}
247+			}
248+		}
249+
250+		return CreateAccountMsg(user)
251+	}
252+}
M tui/createkey/create.go
+36, -39
  1@@ -9,6 +9,7 @@ import (
  2 	"github.com/picosh/pico/db"
  3 	"github.com/picosh/pico/shared"
  4 	"github.com/picosh/pico/tui/common"
  5+	"github.com/picosh/pico/tui/pages"
  6 	"golang.org/x/crypto/ssh"
  7 )
  8 
  9@@ -39,12 +40,8 @@ type errMsg struct {
 10 func (e errMsg) Error() string { return e.err.Error() }
 11 
 12 type Model struct {
 13-	Done bool
 14-	Quit bool
 15+	shared common.SharedModel
 16 
 17-	dbpool db.DB
 18-	user   *db.User
 19-	styles common.Styles
 20 	state  state
 21 	newKey string
 22 	index  index
 23@@ -57,10 +54,10 @@ type Model struct {
 24 func (m *Model) updateFocus() {
 25 	if m.index == textInput && !m.input.Focused() {
 26 		m.input.Focus()
 27-		m.input.Prompt = m.styles.FocusedPrompt.String()
 28+		m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
 29 	} else if m.index != textInput && m.input.Focused() {
 30 		m.input.Blur()
 31-		m.input.Prompt = m.styles.Prompt.String()
 32+		m.input.Prompt = m.shared.Styles.Prompt.String()
 33 	}
 34 }
 35 
 36@@ -85,20 +82,17 @@ func (m *Model) indexBackward() {
 37 }
 38 
 39 // NewModel returns a new username model in its initial state.
 40-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
 41+func NewModel(shared common.SharedModel) Model {
 42 	im := input.New()
 43-	im.Cursor.Style = styles.Cursor
 44+	im.Cursor.Style = shared.Styles.Cursor
 45 	im.Placeholder = "ssh-ed25519 AAAA..."
 46-	im.Prompt = styles.FocusedPrompt.String()
 47+	im.Prompt = shared.Styles.FocusedPrompt.String()
 48 	im.CharLimit = 2049
 49 	im.Focus()
 50 
 51 	return Model{
 52-		Done:   false,
 53-		Quit:   false,
 54-		dbpool: dbpool,
 55-		user:   user,
 56-		styles: styles,
 57+		shared: shared,
 58+
 59 		state:  ready,
 60 		newKey: "",
 61 		index:  textInput,
 62@@ -117,12 +111,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 63 	switch msg := msg.(type) {
 64 	case tea.KeyMsg:
 65 		switch msg.Type {
 66-		case tea.KeyCtrlC: // quit
 67-			m.Quit = true
 68-			return m, nil
 69 		case tea.KeyEscape: // exit this mini-app
 70-			m.Done = true
 71-			return m, nil
 72+			return m, pages.Navigate(pages.PubkeysPage)
 73 
 74 		default:
 75 			// Ignore keys if we're submitting
 76@@ -159,10 +149,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 77 					m.errMsg = ""
 78 					m.newKey = strings.TrimSpace(m.input.Value())
 79 
 80-					return m, addPublicKey(m)
 81-				case cancelButton: // Exit this mini-app
 82-					m.Done = true
 83-					return m, nil
 84+					return m, m.addPublicKey()
 85+				case cancelButton:
 86+					return m, pages.Navigate(pages.PubkeysPage)
 87 				}
 88 			}
 89 
 90@@ -178,32 +167,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 91 			return m, nil
 92 		}
 93 
 94+	case KeySetMsg:
 95+		return m, pages.Navigate(pages.PubkeysPage)
 96+
 97 	case KeyInvalidMsg:
 98 		m.state = ready
 99-		head := m.styles.Error.Render("Invalid public key. ")
100+		head := m.shared.Styles.Error.Render("Invalid public key. ")
101 		helpMsg := "Public keys must but in the correct format"
102-		body := m.styles.Subtle.Render(helpMsg)
103-		m.errMsg = m.styles.Wrap.Render(head + body)
104+		body := m.shared.Styles.Subtle.Render(helpMsg)
105+		m.errMsg = m.shared.Styles.Wrap.Render(head + body)
106 
107 		return m, nil
108 
109 	case KeyTakenMsg:
110 		m.state = ready
111-		head := m.styles.Error.Render("Invalid public key. ")
112+		head := m.shared.Styles.Error.Render("Invalid public key. ")
113 		helpMsg := "Public key is associated with another user"
114-		body := m.styles.Subtle.Render(helpMsg)
115-		m.errMsg = m.styles.Wrap.Render(head + body)
116+		body := m.shared.Styles.Subtle.Render(helpMsg)
117+		m.errMsg = m.shared.Styles.Wrap.Render(head + body)
118 
119 		return m, nil
120 
121 	case errMsg:
122 		m.state = ready
123-		head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
124-		body := m.styles.Subtle.Render(msg.Error())
125-		m.errMsg = m.styles.Wrap.Render(head + body)
126+		head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
127+		body := m.shared.Styles.Subtle.Render(msg.Error())
128+		m.errMsg = m.shared.Styles.Wrap.Render(head + body)
129 
130 		return m, nil
131 
132+	// leaving page so reset model
133+	case pages.NavigateMsg:
134+		next := NewModel(m.shared)
135+		return next, next.Init()
136+
137 	default:
138 		var cmd tea.Cmd
139 		m.input, cmd = m.input.Update(msg) // Do we still need this?
140@@ -218,10 +215,10 @@ func (m Model) View() string {
141 	s += m.input.View() + "\n\n"
142 
143 	if m.state == submitting {
144-		s += spinnerView(m)
145+		s += m.spinnerView()
146 	} else {
147-		s += common.OKButtonView(m.styles, m.index == 1, true)
148-		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
149+		s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
150+		s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
151 		if m.errMsg != "" {
152 			s += "\n\n" + m.errMsg
153 		}
154@@ -230,11 +227,11 @@ func (m Model) View() string {
155 	return s
156 }
157 
158-func spinnerView(m Model) string {
159+func (m Model) spinnerView() string {
160 	return "Submitting..."
161 }
162 
163-func addPublicKey(m Model) tea.Cmd {
164+func (m *Model) addPublicKey() tea.Cmd {
165 	return func() tea.Msg {
166 		pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(m.newKey))
167 		if err != nil {
168@@ -245,7 +242,7 @@ func addPublicKey(m Model) tea.Cmd {
169 		if err != nil {
170 			return KeyInvalidMsg{}
171 		}
172-		err = m.dbpool.InsertPublicKey(m.user.ID, key, comment, nil)
173+		err = m.shared.Dbpool.InsertPublicKey(m.shared.User.ID, key, comment, nil)
174 		if err != nil {
175 			if errors.Is(err, db.ErrPublicKeyTaken) {
176 				return KeyTakenMsg{}
M tui/createtoken/create.go
+93, -109
  1@@ -6,8 +6,8 @@ import (
  2 
  3 	input "github.com/charmbracelet/bubbles/textinput"
  4 	tea "github.com/charmbracelet/bubbletea"
  5-	"github.com/picosh/pico/db"
  6 	"github.com/picosh/pico/tui/common"
  7+	"github.com/picosh/pico/tui/pages"
  8 )
  9 
 10 type state int
 11@@ -26,8 +26,6 @@ const (
 12 	cancelButton
 13 )
 14 
 15-type TokenDismissed int
 16-
 17 type TokenSetMsg struct {
 18 	token string
 19 }
 20@@ -39,12 +37,8 @@ type errMsg struct {
 21 func (e errMsg) Error() string { return e.err.Error() }
 22 
 23 type Model struct {
 24-	Done bool
 25-	Quit bool
 26+	shared common.SharedModel
 27 
 28-	dbpool    db.DB
 29-	user      *db.User
 30-	styles    common.Styles
 31 	state     state
 32 	tokenName string
 33 	token     string
 34@@ -53,21 +47,42 @@ type Model struct {
 35 	input     input.Model
 36 }
 37 
 38+// NewModel returns a new username model in its initial state.
 39+func NewModel(shared common.SharedModel) Model {
 40+	im := input.New()
 41+	im.Cursor.Style = shared.Styles.Cursor
 42+	im.Placeholder = "A name used for your reference"
 43+	im.Prompt = shared.Styles.FocusedPrompt.String()
 44+	im.CharLimit = 256
 45+	im.Focus()
 46+
 47+	return Model{
 48+		shared: shared,
 49+
 50+		state:     ready,
 51+		tokenName: "",
 52+		token:     "",
 53+		index:     textInput,
 54+		errMsg:    "",
 55+		input:     im,
 56+	}
 57+}
 58+
 59 // updateFocus updates the focused states in the model based on the current
 60 // focus index.
 61 func (m *Model) updateFocus() {
 62 	if m.index == textInput && !m.input.Focused() {
 63 		m.input.Focus()
 64-		m.input.Prompt = m.styles.FocusedPrompt.String()
 65+		m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
 66 	} else if m.index != textInput && m.input.Focused() {
 67 		m.input.Blur()
 68-		m.input.Prompt = m.styles.Prompt.String()
 69+		m.input.Prompt = m.shared.Styles.Prompt.String()
 70 	}
 71 }
 72 
 73 // Move the focus index one unit forward.
 74 func (m *Model) indexForward() {
 75-	m.index++
 76+	m.index += 1
 77 	if m.index > cancelButton {
 78 		m.index = textInput
 79 	}
 80@@ -77,7 +92,7 @@ func (m *Model) indexForward() {
 81 
 82 // Move the focus index one unit backwards.
 83 func (m *Model) indexBackward() {
 84-	m.index--
 85+	m.index -= 1
 86 	if m.index < textInput {
 87 		m.index = cancelButton
 88 	}
 89@@ -85,30 +100,6 @@ func (m *Model) indexBackward() {
 90 	m.updateFocus()
 91 }
 92 
 93-// NewModel returns a new username model in its initial state.
 94-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
 95-	im := input.New()
 96-	im.Cursor.Style = styles.Cursor
 97-	im.Placeholder = "A name used for your reference"
 98-	im.Prompt = styles.FocusedPrompt.String()
 99-	im.CharLimit = 256
100-	im.Focus()
101-
102-	return Model{
103-		Done:      false,
104-		Quit:      false,
105-		dbpool:    dbpool,
106-		user:      user,
107-		styles:    styles,
108-		state:     ready,
109-		tokenName: "",
110-		token:     "",
111-		index:     textInput,
112-		errMsg:    "",
113-		input:     im,
114-	}
115-}
116-
117 // Init is the Bubble Tea initialization function.
118 func (m Model) Init() tea.Cmd {
119 	return input.Blink
120@@ -118,74 +109,64 @@ func (m Model) Init() tea.Cmd {
121 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122 	switch msg := msg.(type) {
123 	case tea.KeyMsg:
124-		switch msg.Type {
125-		case tea.KeyCtrlC: // quit
126-			m.Quit = true
127-			return m, dismiss
128-		case tea.KeyEscape: // exit this mini-app
129-			m.Done = true
130-			return m, dismiss
131-
132-		default:
133-			// Ignore keys if we're submitting
134-			if m.state == submitting {
135-				return m, nil
136-			}
137+		// Ignore keys if we're submitting
138+		if m.state == submitting {
139+			return m, nil
140+		}
141 
142-			switch msg.String() {
143-			case "tab":
144+		switch msg.String() {
145+		case "q", "esc":
146+			return m, pages.Navigate(pages.TokensPage)
147+		case "tab":
148+			m.indexForward()
149+		case "shift+tab":
150+			m.indexBackward()
151+		case "l", "k", "right":
152+			if m.index != textInput {
153 				m.indexForward()
154-			case "shift+tab":
155+			}
156+		case "h", "j", "left":
157+			if m.index != textInput {
158 				m.indexBackward()
159-			case "l", "k", "right":
160-				if m.index != textInput {
161-					m.indexForward()
162-				}
163-			case "h", "j", "left":
164-				if m.index != textInput {
165-					m.indexBackward()
166-				}
167-			case "up", "down":
168-				if m.index == textInput {
169-					m.indexForward()
170-				} else {
171-					m.index = textInput
172-					m.updateFocus()
173-				}
174-			case "enter":
175-				switch m.index {
176-				case textInput:
177-					fallthrough
178-				case okButton: // Submit the form
179-					// form already submitted so ok button exits
180-					if m.state == submitted {
181-						m.Done = true
182-						return m, dismiss
183-					}
184-
185-					m.state = submitting
186-					m.errMsg = ""
187-					m.tokenName = strings.TrimSpace(m.input.Value())
188-
189-					return m, addToken(m)
190-				case cancelButton: // Exit this mini-app
191-					m.Done = true
192-					return m, dismiss
193-				}
194 			}
195-
196-			// Pass messages through to the input element if that's the element
197-			// in focus
198+		case "up", "down":
199 			if m.index == textInput {
200-				var cmd tea.Cmd
201-				m.input, cmd = m.input.Update(msg)
202+				m.indexForward()
203+			} else {
204+				m.index = textInput
205+				m.updateFocus()
206+			}
207+		case "enter":
208+			switch m.index {
209+			case textInput:
210+				fallthrough
211+			case okButton: // Submit the form
212+				// form already submitted so ok button exits
213+				if m.state == submitted {
214+					return m, pages.Navigate(pages.TokensPage)
215+				}
216+
217+				m.state = submitting
218+				m.errMsg = ""
219+				m.tokenName = strings.TrimSpace(m.input.Value())
220 
221-				return m, cmd
222+				return m, m.addToken()
223+			case cancelButton:
224+				return m, pages.Navigate(pages.TokensPage)
225 			}
226+		}
227 
228-			return m, nil
229+		// Pass messages through to the input element if that's the element
230+		// in focus
231+		if m.index == textInput {
232+			var cmd tea.Cmd
233+			m.input, cmd = m.input.Update(msg)
234+
235+			return m, cmd
236 		}
237 
238+		return m, nil
239+
240 	case TokenSetMsg:
241 		m.state = submitted
242 		m.token = msg.token
243@@ -193,12 +174,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244 
245 	case errMsg:
246 		m.state = ready
247-		head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
248-		body := m.styles.Subtle.Render(msg.Error())
249-		m.errMsg = m.styles.Wrap.Render(head + body)
250+		head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
251+		body := m.shared.Styles.Subtle.Render(msg.Error())
252+		m.errMsg = m.shared.Styles.Wrap.Render(head + body)
253 
254 		return m, nil
255 
256+	// leaving page so reset model
257+	case pages.NavigateMsg:
258+		next := NewModel(m.shared)
259+		return next, next.Init()
260+
261 	default:
262 		var cmd tea.Cmd
263 		m.input, cmd = m.input.Update(msg) // Do we still need this?
264@@ -213,14 +199,14 @@ func (m Model) View() string {
265 	s += m.input.View() + "\n\n"
266 
267 	if m.state == submitting {
268-		s += spinnerView(m)
269+		s += spinnerView()
270 	} else if m.state == submitted {
271 		s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
272 		s += "After you exit this screen you will *not* be able to see it again.\n\n"
273-		s += common.OKButtonView(m.styles, m.index == 1, true)
274+		s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
275 	} else {
276-		s += common.OKButtonView(m.styles, m.index == 1, true)
277-		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
278+		s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
279+		s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
280 		if m.errMsg != "" {
281 			s += "\n\n" + m.errMsg
282 		}
283@@ -229,15 +215,9 @@ func (m Model) View() string {
284 	return s
285 }
286 
287-func spinnerView(m Model) string {
288-	return "Submitting..."
289-}
290-
291-func dismiss() tea.Msg { return TokenDismissed(1) }
292-
293-func addToken(m Model) tea.Cmd {
294+func (m *Model) addToken() tea.Cmd {
295 	return func() tea.Msg {
296-		token, err := m.dbpool.InsertToken(m.user.ID, m.tokenName)
297+		token, err := m.shared.Dbpool.InsertToken(m.shared.User.ID, m.tokenName)
298 		if err != nil {
299 			return errMsg{err}
300 		}
301@@ -245,3 +225,7 @@ func addToken(m Model) tea.Cmd {
302 		return TokenSetMsg{token}
303 	}
304 }
305+
306+func spinnerView() string {
307+	return "Submitting..."
308+}
D tui/info/info.go
+0, -94
 1@@ -1,94 +0,0 @@
 2-package info
 3-
 4-import (
 5-	tea "github.com/charmbracelet/bubbletea"
 6-	"github.com/picosh/pico/db"
 7-	"github.com/picosh/pico/tui/common"
 8-)
 9-
10-type errMsg struct {
11-	err error
12-}
13-
14-// Error satisfies the error interface.
15-func (e errMsg) Error() string {
16-	return e.err.Error()
17-}
18-
19-// Model stores the state of the info user interface.
20-type Model struct {
21-	Quit            bool // signals it's time to exit the whole application
22-	Err             error
23-	User            *db.User
24-	PlusFeatureFlag *db.FeatureFlag
25-	styles          common.Styles
26-}
27-
28-// NewModel returns a new Model in its initial state.
29-func NewModel(styles common.Styles, user *db.User, ff *db.FeatureFlag) Model {
30-	return Model{
31-		Quit:            false,
32-		User:            user,
33-		styles:          styles,
34-		PlusFeatureFlag: ff,
35-	}
36-}
37-
38-// Update is the Bubble Tea update loop.
39-func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
40-	var cmd tea.Cmd
41-
42-	switch msg := msg.(type) {
43-	case tea.KeyMsg:
44-		switch msg.String() {
45-		case "ctrl+c", "esc", "q":
46-			m.Quit = true
47-			return m, nil
48-		}
49-	case errMsg:
50-		// If there's an error we print the error and exit
51-		m.Err = msg
52-		m.Quit = true
53-		return m, nil
54-	}
55-
56-	return m, cmd
57-}
58-
59-// View renders the current view from the model.
60-func (m Model) View() string {
61-	if m.Err != nil {
62-		return "error: " + m.Err.Error()
63-	} else if m.User == nil {
64-		return " Authenticating..."
65-	}
66-	return m.bioView()
67-}
68-
69-func (m Model) bioView() string {
70-	var username string
71-	if m.User.Name != "" {
72-		username = m.User.Name
73-	} else {
74-		username = m.styles.Subtle.Render("(none set)")
75-	}
76-
77-	plus := "No"
78-	expires := ""
79-	if m.PlusFeatureFlag != nil {
80-		plus = "Yes"
81-		expires = m.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
82-	}
83-
84-	vals := []string{
85-		"Username", username,
86-		"Joined", m.User.CreatedAt.Format("02 Jan 2006"),
87-		"Pico+", plus,
88-	}
89-
90-	if expires != "" {
91-		vals = append(vals, "Pico+ Expires At", expires)
92-	}
93-
94-	return common.KeyValueView(m.styles, vals...)
95-}
A tui/mdw.go
+42, -0
 1@@ -0,0 +1,42 @@
 2+package tui
 3+
 4+import (
 5+	tea "github.com/charmbracelet/bubbletea"
 6+	"github.com/charmbracelet/ssh"
 7+	bm "github.com/charmbracelet/wish/bubbletea"
 8+	"github.com/picosh/pico/db/postgres"
 9+	"github.com/picosh/pico/shared"
10+	"github.com/picosh/pico/tui/common"
11+)
12+
13+func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
14+	return func(sesh ssh.Session) (tea.Model, []tea.ProgramOption) {
15+		logger := cfg.Logger
16+
17+		_, _, active := sesh.Pty()
18+		if !active {
19+			logger.Info("no active terminal, skipping")
20+			return nil, nil
21+		}
22+
23+		dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
24+		renderer := bm.MakeRenderer(sesh)
25+		styles := common.DefaultStyles(renderer)
26+
27+		shrd := common.SharedModel{
28+			Session: sesh,
29+			Cfg:     cfg,
30+			Dbpool:  dbpool,
31+			Styles:  styles,
32+			Width:   80,
33+			Height:  24,
34+			Logger:  logger,
35+		}
36+
37+		m := NewUI(shrd)
38+
39+		opts := bm.MakeOptions(sesh)
40+		opts = append(opts, tea.WithAltScreen())
41+		return m, opts
42+	}
43+}
A tui/menu/menu.go
+172, -0
  1@@ -0,0 +1,172 @@
  2+package menu
  3+
  4+import (
  5+	tea "github.com/charmbracelet/bubbletea"
  6+	"github.com/muesli/reflow/indent"
  7+	"github.com/picosh/pico/tui/common"
  8+)
  9+
 10+// menuChoice represents a chosen menu item.
 11+type menuChoice int
 12+
 13+type MenuChoiceMsg struct {
 14+	MenuChoice menuChoice
 15+}
 16+
 17+const (
 18+	KeysChoice menuChoice = iota
 19+	TokensChoice
 20+	NotificationsChoice
 21+	PlusChoice
 22+	ChatChoice
 23+	ExitChoice
 24+	UnsetChoice // set when no choice has been made
 25+)
 26+
 27+// menu text corresponding to menu choices. these are presented to the user.
 28+var menuChoices = map[menuChoice]string{
 29+	KeysChoice:          "Manage pubkeys",
 30+	TokensChoice:        "Manage tokens",
 31+	NotificationsChoice: "Notifications",
 32+	PlusChoice:          "Pico+",
 33+	ChatChoice:          "Chat",
 34+	ExitChoice:          "Exit",
 35+}
 36+
 37+type Model struct {
 38+	shared     common.SharedModel
 39+	err        error
 40+	menuIndex  int
 41+	menuChoice menuChoice
 42+}
 43+
 44+func NewModel(shared common.SharedModel) Model {
 45+	return Model{
 46+		shared:     shared,
 47+		menuChoice: UnsetChoice,
 48+	}
 49+}
 50+
 51+func (m Model) Init() tea.Cmd {
 52+	return nil
 53+}
 54+
 55+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 56+	var (
 57+		cmds []tea.Cmd
 58+	)
 59+
 60+	switch msg := msg.(type) {
 61+	case tea.KeyMsg:
 62+		switch msg.Type {
 63+		case tea.KeyCtrlC:
 64+			m.shared.Dbpool.Close()
 65+			return m, tea.Quit
 66+		}
 67+
 68+		switch msg.String() {
 69+		case "q", "esc":
 70+			m.shared.Dbpool.Close()
 71+			return m, tea.Quit
 72+
 73+		case "up", "k":
 74+			m.menuIndex--
 75+			if m.menuIndex < 0 {
 76+				m.menuIndex = len(menuChoices) - 1
 77+			}
 78+
 79+		case "enter":
 80+			m.menuChoice = menuChoice(m.menuIndex)
 81+			cmds = append(cmds, MenuMsg(m.menuChoice))
 82+
 83+		case "down", "j":
 84+			m.menuIndex++
 85+			if m.menuIndex >= len(menuChoices) {
 86+				m.menuIndex = 0
 87+			}
 88+		}
 89+	}
 90+
 91+	return m, tea.Batch(cmds...)
 92+}
 93+
 94+func MenuMsg(choice menuChoice) tea.Cmd {
 95+	return func() tea.Msg {
 96+		return MenuChoiceMsg{
 97+			MenuChoice: choice,
 98+		}
 99+	}
100+}
101+
102+func (m Model) bioView() string {
103+	if m.shared.User == nil {
104+		return "Loading user info..."
105+	}
106+
107+	var username string
108+	if m.shared.User.Name != "" {
109+		username = m.shared.User.Name
110+	} else {
111+		username = m.shared.Styles.Subtle.Render("(none set)")
112+	}
113+
114+	plus := "No"
115+	expires := ""
116+	if m.shared.PlusFeatureFlag != nil {
117+		plus = "Yes"
118+		expires = m.shared.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
119+	}
120+
121+	vals := []string{
122+		"Username", username,
123+		"Joined", m.shared.User.CreatedAt.Format("02 Jan 2006"),
124+		"Pico+", plus,
125+	}
126+
127+	if expires != "" {
128+		vals = append(vals, "Pico+ Expires At", expires)
129+	}
130+
131+	return common.KeyValueView(m.shared.Styles, vals...)
132+}
133+
134+func (m Model) menuView() string {
135+	var s string
136+	for i := 0; i < len(menuChoices); i++ {
137+		e := "  "
138+		menuItem := menuChoices[menuChoice(i)]
139+		if i == m.menuIndex {
140+			e = m.shared.Styles.SelectionMarker.String() +
141+				m.shared.Styles.SelectedMenuItem.Render(menuItem)
142+		} else {
143+			e += menuItem
144+		}
145+		if i < len(menuChoices)-1 {
146+			e += "\n"
147+		}
148+		s += e
149+	}
150+
151+	return s
152+}
153+
154+func (m Model) errorView(err error) string {
155+	head := m.shared.Styles.Error.Render("Error: ")
156+	body := m.shared.Styles.Subtle.Render(err.Error())
157+	msg := m.shared.Styles.Wrap.Render(head + body)
158+	return "\n\n" + indent.String(msg, 2)
159+}
160+
161+func (m Model) footerView() string {
162+	if m.err != nil {
163+		return m.errorView(m.err)
164+	}
165+	return "\n\n" + common.HelpView(m.shared.Styles, "j/k, ↑/↓: choose", "enter: select")
166+}
167+
168+func (m Model) View() string {
169+	s := m.bioView()
170+	s += "\n\n" + m.menuView()
171+	s += m.footerView()
172+	return s
173+}
M tui/notifications/notifications.go
+26, -35
  1@@ -8,6 +8,7 @@ import (
  2 	"github.com/charmbracelet/glamour"
  3 	"github.com/picosh/pico/db"
  4 	"github.com/picosh/pico/tui/common"
  5+	"github.com/picosh/pico/tui/pages"
  6 )
  7 
  8 func NotificationsView(dbpool db.DB, userID string) string {
  9@@ -53,11 +54,11 @@ Create a feeds file (e.g. pico.txt):`, url)
 10 
 11 // Model holds the state of the username UI.
 12 type Model struct {
 13+	shared common.SharedModel
 14+
 15 	Done bool // true when it's time to exit this view
 16 	Quit bool // true when the user wants to quit the whole program
 17 
 18-	styles   common.Styles
 19-	user     *db.User
 20 	viewport viewport.Model
 21 }
 22 
 23@@ -69,56 +70,46 @@ func headerWidth(w int) int {
 24 	return w - 2
 25 }
 26 
 27-// NewModel returns a new username model in its initial state.
 28-func NewModel(styles common.Styles, dbpool db.DB, user *db.User, termSize tea.WindowSizeMsg) Model {
 29-	hh := headerHeight(styles)
 30-	viewport := viewport.New(headerWidth(termSize.Width), termSize.Height-hh)
 31+func NewModel(shared common.SharedModel) Model {
 32+	hh := headerHeight(shared.Styles)
 33+	viewport := viewport.New(headerWidth(shared.Width), shared.Height-hh)
 34 	viewport.YPosition = hh
 35-	if user != nil {
 36-		viewport.SetContent(NotificationsView(dbpool, user.ID))
 37+	if shared.User != nil {
 38+		viewport.SetContent(NotificationsView(shared.Dbpool, shared.User.ID))
 39 	}
 40 
 41 	return Model{
 42+		shared: shared,
 43+
 44 		Done:     false,
 45 		Quit:     false,
 46-		styles:   styles,
 47-		user:     user,
 48 		viewport: viewport,
 49 	}
 50 }
 51 
 52-// Update is the Bubble Tea update loop.
 53-func Update(msg tea.Msg, m Model, termSize tea.WindowSizeMsg) (Model, tea.Cmd) {
 54-	var cmd tea.Cmd
 55-	var cmds []tea.Cmd
 56+func (m Model) Init() tea.Cmd {
 57+	return nil
 58+}
 59 
 60+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 61 	switch msg := msg.(type) {
 62+	case tea.WindowSizeMsg:
 63+		m.viewport.Width = headerWidth(m.shared.Width)
 64+		hh := headerHeight(m.shared.Styles)
 65+		m.viewport.Height = m.shared.Height - hh
 66+
 67 	case tea.KeyMsg:
 68-		switch msg.Type {
 69-		case tea.KeyCtrlC:
 70-			m.Quit = true
 71-		case tea.KeyEscape:
 72-			m.Done = true
 73-
 74-		default:
 75-			switch msg.String() {
 76-			case "q":
 77-				m.Done = true
 78-			}
 79+		switch msg.String() {
 80+		case "q", "esc":
 81+			return m, pages.Navigate(pages.MenuPage)
 82 		}
 83 	}
 84 
 85-	m.viewport.Width = headerWidth(termSize.Width)
 86-	hh := headerHeight(m.styles)
 87-	m.viewport.Height = termSize.Height - hh
 88+	var cmd tea.Cmd
 89 	m.viewport, cmd = m.viewport.Update(msg)
 90-	cmds = append(cmds, cmd)
 91-
 92-	return m, tea.Batch(cmds...)
 93+	return m, cmd
 94 }
 95 
 96-// View renders current view from the model.
 97-func View(m Model) string {
 98-	s := m.viewport.View()
 99-	return s
100+func (m Model) View() string {
101+	return m.viewport.View()
102 }
A tui/pages/pages.go
+24, -0
 1@@ -0,0 +1,24 @@
 2+package pages
 3+
 4+import tea "github.com/charmbracelet/bubbletea"
 5+
 6+type Page int
 7+
 8+const (
 9+	MenuPage Page = iota
10+	CreateAccountPage
11+	CreatePubkeyPage
12+	CreateTokenPage
13+	PubkeysPage
14+	TokensPage
15+	NotificationsPage
16+	PlusPage
17+)
18+
19+type NavigateMsg struct{ Page }
20+
21+func Navigate(page Page) tea.Cmd {
22+	return func() tea.Msg {
23+		return NavigateMsg{page}
24+	}
25+}
M tui/plus/plus.go
+24, -40
  1@@ -7,9 +7,8 @@ import (
  2 	"github.com/charmbracelet/bubbles/viewport"
  3 	tea "github.com/charmbracelet/bubbletea"
  4 	"github.com/charmbracelet/glamour"
  5-	"github.com/charmbracelet/ssh"
  6-	"github.com/picosh/pico/db"
  7 	"github.com/picosh/pico/tui/common"
  8+	"github.com/picosh/pico/tui/pages"
  9 )
 10 
 11 func PlusView(username string) string {
 12@@ -77,11 +76,7 @@ we plan on continuing to include more services as we build them.`, url)
 13 
 14 // Model holds the state of the username UI.
 15 type Model struct {
 16-	Done bool // true when it's time to exit this view
 17-	Quit bool // true when the user wants to quit the whole program
 18-
 19-	styles   common.Styles
 20-	user     *db.User
 21+	shared   common.SharedModel
 22 	viewport viewport.Model
 23 }
 24 
 25@@ -94,55 +89,44 @@ func headerWidth(w int) int {
 26 }
 27 
 28 // NewModel returns a new username model in its initial state.
 29-func NewModel(styles common.Styles, user *db.User, session ssh.Session, termSize tea.WindowSizeMsg) Model {
 30-	hh := headerHeight(styles)
 31-	viewport := viewport.New(headerWidth(termSize.Width), termSize.Height-hh)
 32+func NewModel(shared common.SharedModel) Model {
 33+	hh := headerHeight(shared.Styles)
 34+	viewport := viewport.New(headerWidth(shared.Width), shared.Height-hh)
 35 	viewport.YPosition = hh
 36-	if user != nil {
 37-		viewport.SetContent(PlusView(user.Name))
 38+	if shared.User != nil {
 39+		viewport.SetContent(PlusView(shared.User.Name))
 40 	}
 41 
 42 	return Model{
 43-		Done:     false,
 44-		Quit:     false,
 45-		styles:   styles,
 46-		user:     user,
 47+		shared:   shared,
 48 		viewport: viewport,
 49 	}
 50 }
 51 
 52-// Update is the Bubble Tea update loop.
 53-func Update(msg tea.Msg, m Model, termSize tea.WindowSizeMsg) (Model, tea.Cmd) {
 54-	var cmd tea.Cmd
 55-	var cmds []tea.Cmd
 56+func (m Model) Init() tea.Cmd {
 57+	return nil
 58+}
 59 
 60+// Update is the Bubble Tea update loop.
 61+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 62 	switch msg := msg.(type) {
 63+	case tea.WindowSizeMsg:
 64+		m.viewport.Width = headerWidth(m.shared.Width)
 65+		hh := headerHeight(m.shared.Styles)
 66+		m.viewport.Height = m.shared.Height - hh
 67 	case tea.KeyMsg:
 68-		switch msg.Type {
 69-		case tea.KeyCtrlC:
 70-			m.Quit = true
 71-		case tea.KeyEscape:
 72-			m.Done = true
 73-
 74-		default:
 75-			switch msg.String() {
 76-			case "q":
 77-				m.Done = true
 78-			}
 79+		switch msg.String() {
 80+		case "q", "esc":
 81+			return m, pages.Navigate(pages.MenuPage)
 82 		}
 83 	}
 84 
 85-	m.viewport.Width = headerWidth(termSize.Width)
 86-	hh := headerHeight(m.styles)
 87-	m.viewport.Height = termSize.Height - hh
 88+	var cmd tea.Cmd
 89 	m.viewport, cmd = m.viewport.Update(msg)
 90-	cmds = append(cmds, cmd)
 91-
 92-	return m, tea.Batch(cmds...)
 93+	return m, cmd
 94 }
 95 
 96 // View renders current view from the model.
 97-func View(m Model) string {
 98-	s := m.viewport.View()
 99-	return s
100+func (m Model) View() string {
101+	return m.viewport.View()
102 }
R tui/keys/keys.go => tui/pubkeys/keys.go
+97, -159
  1@@ -1,13 +1,11 @@
  2-package keys
  3+package pubkeys
  4 
  5 import (
  6-	"log/slog"
  7-
  8 	pager "github.com/charmbracelet/bubbles/paginator"
  9 	tea "github.com/charmbracelet/bubbletea"
 10 	"github.com/picosh/pico/db"
 11 	"github.com/picosh/pico/tui/common"
 12-	"github.com/picosh/pico/tui/createkey"
 13+	"github.com/picosh/pico/tui/pages"
 14 )
 15 
 16 const keysPerPage = 4
 17@@ -20,7 +18,6 @@ const (
 18 	stateDeletingKey
 19 	stateDeletingActiveKey
 20 	stateDeletingAccount
 21-	stateCreateKey
 22 	stateQuitting
 23 )
 24 
 25@@ -45,19 +42,34 @@ type (
 26 
 27 // Model is the Tea state model for this user interface.
 28 type Model struct {
 29-	logger         *slog.Logger
 30-	dbpool         db.DB
 31-	user           *db.User
 32-	styles         common.Styles
 33-	pager          pager.Model
 34+	shared common.SharedModel
 35+
 36 	state          state
 37 	err            error
 38 	activeKeyIndex int             // index of the key in the below slice which is currently in use
 39 	keys           []*db.PublicKey // keys linked to user's account
 40 	index          int             // index of selected key in relation to the current page
 41-	Exit           bool
 42-	Quit           bool
 43-	createKey      createkey.Model
 44+
 45+	pager pager.Model
 46+}
 47+
 48+// NewModel creates a new model with defaults.
 49+func NewModel(shared common.SharedModel) Model {
 50+	p := pager.New()
 51+	p.PerPage = keysPerPage
 52+	p.Type = pager.Dots
 53+	p.InactiveDot = shared.Styles.InactivePagination.Render("•")
 54+
 55+	return Model{
 56+		shared: shared,
 57+
 58+		pager:          p,
 59+		state:          stateLoading,
 60+		err:            nil,
 61+		activeKeyIndex: -1,
 62+		keys:           []*db.PublicKey{},
 63+		index:          0,
 64+	}
 65 }
 66 
 67 // getSelectedIndex returns the index of the cursor in relation to the total
 68@@ -78,105 +90,71 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
 69 	m.index = min(m.index, numItems-1)
 70 }
 71 
 72-// NewModel creates a new model with defaults.
 73-func NewModel(styles common.Styles, logger *slog.Logger, dbpool db.DB, user *db.User) Model {
 74-	p := pager.New()
 75-	p.PerPage = keysPerPage
 76-	p.Type = pager.Dots
 77-	p.InactiveDot = styles.InactivePagination.Render("•")
 78-
 79-	return Model{
 80-		logger:         logger,
 81-		dbpool:         dbpool,
 82-		user:           user,
 83-		styles:         styles,
 84-		pager:          p,
 85-		state:          stateLoading,
 86-		err:            nil,
 87-		activeKeyIndex: -1,
 88-		keys:           []*db.PublicKey{},
 89-		index:          0,
 90-		Exit:           false,
 91-		Quit:           false,
 92-	}
 93-}
 94-
 95 // Init is the Tea initialization function.
 96 func (m Model) Init() tea.Cmd {
 97-	return nil
 98+	return FetchKeys(m.shared)
 99 }
100 
101 // Update is the tea update function which handles incoming messages.
102 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 	var (
104 		cmds []tea.Cmd
105-		cmd  tea.Cmd
106 	)
107 
108 	switch msg := msg.(type) {
109 	case tea.KeyMsg:
110-		switch msg.Type {
111-		case tea.KeyCtrlC:
112-			m.Exit = true
113+		switch msg.String() {
114+		case "q", "esc":
115+			return m, pages.Navigate(pages.MenuPage)
116+		case "up", "k":
117+			m.index -= 1
118+			if m.index < 0 && m.pager.Page > 0 {
119+				m.index = m.pager.PerPage - 1
120+				m.pager.PrevPage()
121+			}
122+			m.index = max(0, m.index)
123+		case "down", "j":
124+			itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
125+			m.index += 1
126+			if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
127+				m.index = 0
128+				m.pager.NextPage()
129+			}
130+			m.index = min(itemsOnPage-1, m.index)
131+
132+		case "n":
133+			return m, pages.Navigate(pages.CreatePubkeyPage)
134+
135+		// Delete
136+		case "x":
137+			m.state = stateDeletingKey
138+			m.UpdatePaging(msg)
139 			return m, nil
140-		}
141 
142-		if m.state != stateCreateKey {
143-			switch msg.String() {
144-			case "q", "esc":
145-				m.Exit = true
146-				return m, nil
147-			case "up", "k":
148-				m.index--
149-				if m.index < 0 && m.pager.Page > 0 {
150-					m.index = m.pager.PerPage - 1
151-					m.pager.PrevPage()
152+		// Confirm Delete
153+		case "y":
154+			switch m.state {
155+			case stateDeletingKey:
156+				if len(m.keys) == 1 {
157+					// The user is about to delete her account. Double confirm.
158+					m.state = stateDeletingAccount
159+					return m, nil
160 				}
161-				m.index = max(0, m.index)
162-			case "down", "j":
163-				itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
164-				m.index++
165-				if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
166-					m.index = 0
167-					m.pager.NextPage()
168-				}
169-				m.index = min(itemsOnPage-1, m.index)
170-
171-			case "n":
172-				m.state = stateCreateKey
173-				return m, nil
174-
175-			// Delete
176-			case "x":
177-				m.state = stateDeletingKey
178-				m.UpdatePaging(msg)
179-				return m, nil
180-
181-			// Confirm Delete
182-			case "y":
183-				switch m.state {
184-				case stateDeletingKey:
185-					if len(m.keys) == 1 {
186-						// The user is about to delete her account. Double confirm.
187-						m.state = stateDeletingAccount
188-						return m, nil
189-					}
190-					if m.getSelectedIndex() == m.activeKeyIndex {
191-						// The user is going to delete her active key. Double confirm.
192-						m.state = stateDeletingActiveKey
193-						return m, nil
194-					}
195-					m.state = stateNormal
196-					return m, unlinkKey(m)
197-				case stateDeletingActiveKey:
198-					m.state = stateQuitting
199-					// Active key will be deleted. Remove the key and exit.
200-					return m, unlinkKey(m)
201-				case stateDeletingAccount:
202-					// Account will be deleted. Remove the key and exit.
203-					m.state = stateQuitting
204-					return m, deleteAccount(m)
205+				if m.getSelectedIndex() == m.activeKeyIndex {
206+					// The user is going to delete her active key. Double confirm.
207+					m.state = stateDeletingActiveKey
208+					return m, nil
209 				}
210+				m.state = stateNormal
211+				return m, m.unlinkKey()
212+			case stateDeletingActiveKey:
213+				m.state = stateQuitting
214+				// Active key will be deleted. Remove the key and exit.
215+				return m, m.unlinkKey()
216+			case stateDeletingAccount:
217+				// Account will be deleted. Remove the key and exit.
218+				m.state = stateQuitting
219+				return m, m.deleteAccount()
220 			}
221 		}
222 
223@@ -189,7 +167,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
224 		m.index = 0
225 		m.keys = msg
226 		for i, key := range m.keys {
227-			if key.Key == m.user.PublicKey.Key {
228+			if key.Key == m.shared.User.PublicKey.Key {
229 				m.activeKeyIndex = i
230 			}
231 		}
232@@ -210,22 +188,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233 		// Update cursor
234 		m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
235 		for i, key := range m.keys {
236-			if key.Key == m.user.PublicKey.Key {
237+			if key.Key == m.shared.User.PublicKey.Key {
238 				m.activeKeyIndex = i
239 			}
240 		}
241 
242 		return m, nil
243 
244-	case createkey.KeySetMsg:
245-		m.state = stateNormal
246-		return m, fetchKeys(m.dbpool, m.user)
247-
248+	// leaving page so reset model
249+	case pages.NavigateMsg:
250+		next := NewModel(m.shared)
251+		return next, next.Init()
252 	}
253 
254 	switch m.state {
255-	case stateNormal:
256-		m.createKey = createkey.NewModel(m.styles, m.dbpool, m.user)
257 	case stateDeletingKey:
258 		// If an item is being confirmed for delete, any key (other than the key
259 		// used for confirmation above) cancels the deletion
260@@ -236,40 +212,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261 	}
262 
263 	m.UpdatePaging(msg)
264-
265-	m, cmd = updateChildren(msg, m)
266-	if cmd != nil {
267-		cmds = append(cmds, cmd)
268-	}
269-
270 	return m, tea.Batch(cmds...)
271 }
272 
273-func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
274-	var cmd tea.Cmd
275-
276-	switch m.state {
277-	case stateCreateKey:
278-		newModel, newCmd := m.createKey.Update(msg)
279-		createKeyModel, ok := newModel.(createkey.Model)
280-		if !ok {
281-			panic("could not perform assertion on posts model")
282-		}
283-		m.createKey = createKeyModel
284-		cmd = newCmd
285-		if m.createKey.Done {
286-			m.createKey = createkey.NewModel(m.styles, m.dbpool, m.user) // reset the state
287-			m.state = stateNormal
288-		} else if m.createKey.Quit {
289-			m.state = stateQuitting
290-			return m, tea.Quit
291-		}
292-
293-	}
294-
295-	return m, cmd
296-}
297-
298 // View renders the current UI into a string.
299 func (m Model) View() string {
300 	if m.err != nil {
301@@ -283,13 +228,11 @@ func (m Model) View() string {
302 		s = "Loading...\n\n"
303 	case stateQuitting:
304 		s = "Thanks for using pico.sh!\n"
305-	case stateCreateKey:
306-		s = m.createKey.View()
307 	default:
308 		s = "Here are the keys linked to your pico.sh account.\n\n"
309 
310 		// Keys
311-		s += keysView(m)
312+		s += m.keysView()
313 		if m.pager.TotalPages > 1 {
314 			s += m.pager.View()
315 		}
316@@ -303,14 +246,14 @@ func (m Model) View() string {
317 		case stateDeletingAccount:
318 			s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
319 		default:
320-			s += "\n\n" + helpView(m)
321+			s += "\n\n" + m.helpView()
322 		}
323 	}
324 
325 	return s
326 }
327 
328-func keysView(m Model) string {
329+func (m *Model) keysView() string {
330 	var (
331 		s          string
332 		state      keyState
333@@ -332,7 +275,7 @@ func keysView(m Model) string {
334 		} else {
335 			state = keyNormal
336 		}
337-		s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
338+		s += m.newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
339 	}
340 
341 	// If there aren't enough keys to fill the view, fill the missing parts
342@@ -346,7 +289,7 @@ func keysView(m Model) string {
343 	return s
344 }
345 
346-func helpView(m Model) string {
347+func (m *Model) helpView() string {
348 	var items []string
349 	if len(m.keys) > 1 {
350 		items = append(items, "j/k, ↑/↓: choose")
351@@ -355,24 +298,19 @@ func helpView(m Model) string {
352 		items = append(items, "h/l, ←/→: page")
353 	}
354 	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
355-	return common.HelpView(m.styles, items...)
356+	return common.HelpView(m.shared.Styles, items...)
357 }
358 
359-func (m Model) promptView(prompt string) string {
360-	st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
361+func (m *Model) promptView(prompt string) string {
362+	st := m.shared.Styles.Delete.Copy().MarginTop(2).MarginRight(1)
363 	return st.Render(prompt) +
364-		m.styles.Delete.Render("(y/N)")
365-}
366-
367-// LoadKeys returns the command necessary for loading the keys.
368-func LoadKeys(m Model) tea.Cmd {
369-	return fetchKeys(m.dbpool, m.user)
370+		m.shared.Styles.Delete.Render("(y/N)")
371 }
372 
373-// fetchKeys loads the current set of keys via the charm client.
374-func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
375+// FetchKeys loads the current set of keys via the charm client.
376+func FetchKeys(shrd common.SharedModel) tea.Cmd {
377 	return func() tea.Msg {
378-		ak, err := dbpool.FindKeysForUser(user)
379+		ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
380 		if err != nil {
381 			return errMsg{err}
382 		}
383@@ -381,10 +319,10 @@ func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
384 }
385 
386 // unlinkKey deletes the selected key.
387-func unlinkKey(m Model) tea.Cmd {
388+func (m *Model) unlinkKey() tea.Cmd {
389 	return func() tea.Msg {
390 		id := m.keys[m.getSelectedIndex()].ID
391-		err := m.dbpool.RemoveKeys([]string{id})
392+		err := m.shared.Dbpool.RemoveKeys([]string{id})
393 		if err != nil {
394 			return errMsg{err}
395 		}
396@@ -392,11 +330,11 @@ func unlinkKey(m Model) tea.Cmd {
397 	}
398 }
399 
400-func deleteAccount(m Model) tea.Cmd {
401+func (m *Model) deleteAccount() tea.Cmd {
402 	return func() tea.Msg {
403 		id := m.keys[m.getSelectedIndex()].UserID
404-		m.logger.Info("user requested account deletion", "user", m.user.Name, "id", id)
405-		err := m.dbpool.RemoveUsers([]string{id})
406+		m.shared.Logger.Info("user requested account deletion", "user", m.shared.User.Name, "id", id)
407+		err := m.shared.Dbpool.RemoveUsers([]string{id})
408 		if err != nil {
409 			return errMsg{err}
410 		}
R tui/keys/keyview.go => tui/pubkeys/keyview.go
+2, -2
 1@@ -1,4 +1,4 @@
 2-package keys
 3+package pubkeys
 4 
 5 import (
 6 	"fmt"
 7@@ -93,7 +93,7 @@ func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool
 8 
 9 	var note string
10 	if active {
11-		note = m.styles.Note.Render("• ") + m.styles.Note.Render("Current Key")
12+		note = m.shared.Styles.Note.Render("• ") + m.shared.Styles.Note.Render("Current Key")
13 	}
14 
15 	// Default state
A tui/senpai.go
+40, -0
 1@@ -0,0 +1,40 @@
 2+package tui
 3+
 4+import (
 5+	"io"
 6+
 7+	tea "github.com/charmbracelet/bubbletea"
 8+	"github.com/picosh/pico/shared"
 9+	"github.com/picosh/pico/tui/common"
10+)
11+
12+type SenpaiCmd struct {
13+	shared common.SharedModel
14+}
15+
16+func (m *SenpaiCmd) Run() error {
17+	pass, err := m.shared.Dbpool.UpsertToken(m.shared.User.ID, "pico-chat")
18+	if err != nil {
19+		return err
20+	}
21+	app, err := shared.NewSenpaiApp(m.shared.Session, m.shared.User.Name, pass)
22+	if err != nil {
23+		return err
24+	}
25+	app.Run()
26+	app.Close()
27+	return nil
28+}
29+
30+func (m *SenpaiCmd) SetStdin(io.Reader)  {}
31+func (m *SenpaiCmd) SetStdout(io.Writer) {}
32+func (m *SenpaiCmd) SetStderr(io.Writer) {}
33+
34+func LoadChat(shrd common.SharedModel) tea.Cmd {
35+	sp := &SenpaiCmd{
36+		shared: shrd,
37+	}
38+	return tea.Exec(sp, func(err error) tea.Msg {
39+		return tea.Quit()
40+	})
41+}
M tui/tokens/tokens.go
+59, -117
  1@@ -5,7 +5,7 @@ import (
  2 	tea "github.com/charmbracelet/bubbletea"
  3 	"github.com/picosh/pico/db"
  4 	"github.com/picosh/pico/tui/common"
  5-	"github.com/picosh/pico/tui/createtoken"
  6+	"github.com/picosh/pico/tui/pages"
  7 )
  8 
  9 const keysPerPage = 4
 10@@ -16,7 +16,6 @@ const (
 11 	stateLoading state = iota
 12 	stateNormal
 13 	stateDeletingKey
 14-	stateCreateKey
 15 	stateQuitting
 16 )
 17 
 18@@ -41,18 +40,15 @@ type (
 19 
 20 // Model is the Tea state model for this user interface.
 21 type Model struct {
 22-	dbpool         db.DB
 23-	user           *db.User
 24-	styles         common.Styles
 25-	pager          pager.Model
 26+	shared common.SharedModel
 27+
 28 	state          state
 29 	err            error
 30 	activeKeyIndex int         // index of the key in the below slice which is currently in use
 31 	tokens         []*db.Token // keys linked to user's account
 32 	index          int         // index of selected key in relation to the current page
 33-	Exit           bool
 34-	Quit           bool
 35-	createKey      createtoken.Model
 36+
 37+	pager pager.Model
 38 }
 39 
 40 // getSelectedIndex returns the index of the cursor in relation to the total
 41@@ -74,85 +70,72 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
 42 }
 43 
 44 // NewModel creates a new model with defaults.
 45-func NewModel(styles common.Styles, dbpool db.DB, user *db.User) Model {
 46+func NewModel(shared common.SharedModel) Model {
 47 	p := pager.New()
 48 	p.PerPage = keysPerPage
 49 	p.Type = pager.Dots
 50-	p.InactiveDot = styles.InactivePagination.Render("•")
 51+	p.InactiveDot = shared.Styles.InactivePagination.Render("•")
 52 
 53 	return Model{
 54-		dbpool:         dbpool,
 55-		user:           user,
 56-		styles:         styles,
 57-		pager:          p,
 58+		shared: shared,
 59+
 60 		state:          stateLoading,
 61 		err:            nil,
 62 		activeKeyIndex: -1,
 63 		tokens:         []*db.Token{},
 64 		index:          0,
 65-		Exit:           false,
 66-		Quit:           false,
 67+
 68+		pager: p,
 69 	}
 70 }
 71 
 72 // Init is the Tea initialization function.
 73 func (m Model) Init() tea.Cmd {
 74-	return nil
 75+	return FetchTokens(m.shared)
 76 }
 77 
 78 // Update is the tea update function which handles incoming messages.
 79 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 80 	var (
 81 		cmds []tea.Cmd
 82-		cmd  tea.Cmd
 83 	)
 84 
 85 	switch msg := msg.(type) {
 86 	case tea.KeyMsg:
 87-		switch msg.Type {
 88-		case tea.KeyCtrlC:
 89-			m.Exit = true
 90+		switch msg.String() {
 91+		case "q", "esc":
 92+			return m, pages.Navigate(pages.MenuPage)
 93+		case "up", "k":
 94+			m.index--
 95+			if m.index < 0 && m.pager.Page > 0 {
 96+				m.index = m.pager.PerPage - 1
 97+				m.pager.PrevPage()
 98+			}
 99+			m.index = max(0, m.index)
100+		case "down", "j":
101+			itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
102+			m.index++
103+			if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
104+				m.index = 0
105+				m.pager.NextPage()
106+			}
107+			m.index = min(itemsOnPage-1, m.index)
108+
109+		case "n":
110+			return m, pages.Navigate(pages.CreateTokenPage)
111+
112+		// Delete
113+		case "x":
114+			m.state = stateDeletingKey
115+			m.UpdatePaging(msg)
116 			return m, nil
117-		}
118 
119-		if m.state != stateCreateKey {
120-			switch msg.String() {
121-			case "q", "esc":
122-				m.Exit = true
123-				return m, nil
124-			case "up", "k":
125-				m.index--
126-				if m.index < 0 && m.pager.Page > 0 {
127-					m.index = m.pager.PerPage - 1
128-					m.pager.PrevPage()
129-				}
130-				m.index = max(0, m.index)
131-			case "down", "j":
132-				itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
133-				m.index++
134-				if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
135-					m.index = 0
136-					m.pager.NextPage()
137-				}
138-				m.index = min(itemsOnPage-1, m.index)
139-
140-			case "n":
141-				m.state = stateCreateKey
142-				return m, nil
143-
144-			// Delete
145-			case "x":
146-				m.state = stateDeletingKey
147-				m.UpdatePaging(msg)
148-				return m, nil
149-
150-			// Confirm Delete
151-			case "y":
152-				switch m.state {
153-				case stateDeletingKey:
154-					m.state = stateNormal
155-					return m, unlinkKey(m)
156-				}
157+		// Confirm Delete
158+		case "y":
159+			switch m.state {
160+			case stateDeletingKey:
161+				m.state = stateNormal
162+				return m, m.unlinkKey()
163 			}
164 		}
165 
166@@ -183,15 +166,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167 
168 		return m, nil
169 
170-	case createtoken.TokenDismissed:
171-		m.state = stateNormal
172-		return m, fetchKeys(m.dbpool, m.user)
173-
174+	// leaving page so reset model
175+	case pages.NavigateMsg:
176+		next := NewModel(m.shared)
177+		return next, next.Init()
178 	}
179 
180 	switch m.state {
181-	case stateNormal:
182-		m.createKey = createtoken.NewModel(m.styles, m.dbpool, m.user)
183 	case stateDeletingKey:
184 		// If an item is being confirmed for delete, any key (other than the key
185 		// used for confirmation above) cancels the deletion
186@@ -202,40 +183,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187 	}
188 
189 	m.UpdatePaging(msg)
190-
191-	m, cmd = updateChildren(msg, m)
192-	if cmd != nil {
193-		cmds = append(cmds, cmd)
194-	}
195-
196 	return m, tea.Batch(cmds...)
197 }
198 
199-func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
200-	var cmd tea.Cmd
201-
202-	switch m.state {
203-	case stateCreateKey:
204-		newModel, newCmd := m.createKey.Update(msg)
205-		createKeyModel, ok := newModel.(createtoken.Model)
206-		if !ok {
207-			panic("could not perform assertion on posts model")
208-		}
209-		m.createKey = createKeyModel
210-		cmd = newCmd
211-		if m.createKey.Done {
212-			m.createKey = createtoken.NewModel(m.styles, m.dbpool, m.user) // reset the state
213-			m.state = stateNormal
214-		} else if m.createKey.Quit {
215-			m.state = stateQuitting
216-			return m, tea.Quit
217-		}
218-
219-	}
220-
221-	return m, cmd
222-}
223-
224 // View renders the current UI into a string.
225 func (m Model) View() string {
226 	if m.err != nil {
227@@ -249,8 +199,6 @@ func (m Model) View() string {
228 		s = "Loading...\n\n"
229 	case stateQuitting:
230 		s = "Thanks for using pico.sh!\n"
231-	case stateCreateKey:
232-		s = m.createKey.View()
233 	default:
234 		s = "Here are the tokens linked to your account.\n\n"
235 		s += "A token can be used for connecting to our\nIRC bouncer from your client.\n\n"
236@@ -266,7 +214,7 @@ func (m Model) View() string {
237 		case stateDeletingKey:
238 			s += m.promptView("Delete this key?")
239 		default:
240-			s += "\n\n" + helpView(m)
241+			s += "\n\n" + m.helpView()
242 		}
243 	}
244 
245@@ -292,7 +240,7 @@ func keysView(m Model) string {
246 		} else {
247 			state = keyNormal
248 		}
249-		s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
250+		s += newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
251 	}
252 
253 	// If there aren't enough keys to fill the view, fill the missing parts
254@@ -306,7 +254,7 @@ func keysView(m Model) string {
255 	return s
256 }
257 
258-func helpView(m Model) string {
259+func (m *Model) helpView() string {
260 	var items []string
261 	if len(m.tokens) > 1 {
262 		items = append(items, "j/k, ↑/↓: choose")
263@@ -315,24 +263,18 @@ func helpView(m Model) string {
264 		items = append(items, "h/l, ←/→: page")
265 	}
266 	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
267-	return common.HelpView(m.styles, items...)
268+	return common.HelpView(m.shared.Styles, items...)
269 }
270 
271-func (m Model) promptView(prompt string) string {
272-	st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
273+func (m *Model) promptView(prompt string) string {
274+	st := m.shared.Styles.Delete.Copy().MarginTop(2).MarginRight(1)
275 	return st.Render(prompt) +
276-		m.styles.Delete.Render("(y/N)")
277-}
278-
279-// LoadKeys returns the command necessary for loading the keys.
280-func LoadKeys(m Model) tea.Cmd {
281-	return fetchKeys(m.dbpool, m.user)
282+		m.shared.Styles.Delete.Render("(y/N)")
283 }
284 
285-// fetchKeys loads the current set of keys via the charm client.
286-func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
287+func FetchTokens(shrd common.SharedModel) tea.Cmd {
288 	return func() tea.Msg {
289-		ak, err := dbpool.FindTokensForUser(user.ID)
290+		ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
291 		if err != nil {
292 			return errMsg{err}
293 		}
294@@ -341,10 +283,10 @@ func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
295 }
296 
297 // unlinkKey deletes the selected key.
298-func unlinkKey(m Model) tea.Cmd {
299+func (m *Model) unlinkKey() tea.Cmd {
300 	return func() tea.Msg {
301 		id := m.tokens[m.getSelectedIndex()].ID
302-		err := m.dbpool.RemoveToken(id)
303+		err := m.shared.Dbpool.RemoveToken(id)
304 		if err != nil {
305 			return errMsg{err}
306 		}
M tui/tokens/tokenview.go
+1, -1
1@@ -17,7 +17,7 @@ type styledKey struct {
2 	dateVal   string
3 }
4 
5-func (m Model) newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
6+func newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
7 	date := token.CreatedAt.Format("02 Jan 2006")
8 
9 	// Default state
A tui/ui.go
+194, -0
  1@@ -0,0 +1,194 @@
  2+package tui
  3+
  4+import (
  5+	"errors"
  6+
  7+	tea "github.com/charmbracelet/bubbletea"
  8+	"github.com/charmbracelet/wish"
  9+	"github.com/muesli/reflow/wordwrap"
 10+	"github.com/muesli/reflow/wrap"
 11+	"github.com/picosh/pico/db"
 12+	"github.com/picosh/pico/shared"
 13+	"github.com/picosh/pico/tui/common"
 14+	"github.com/picosh/pico/tui/createaccount"
 15+	"github.com/picosh/pico/tui/createkey"
 16+	"github.com/picosh/pico/tui/createtoken"
 17+	"github.com/picosh/pico/tui/menu"
 18+	"github.com/picosh/pico/tui/notifications"
 19+	"github.com/picosh/pico/tui/pages"
 20+	"github.com/picosh/pico/tui/plus"
 21+	"github.com/picosh/pico/tui/pubkeys"
 22+	"github.com/picosh/pico/tui/tokens"
 23+)
 24+
 25+type state int
 26+
 27+const (
 28+	initState state = iota
 29+	readyState
 30+)
 31+
 32+// Just a generic tea.Model to demo terminal information of ssh.
 33+type UI struct {
 34+	shared common.SharedModel
 35+
 36+	state      state
 37+	activePage pages.Page
 38+	pages      []tea.Model
 39+}
 40+
 41+func NewUI(shared common.SharedModel) *UI {
 42+	m := &UI{
 43+		shared: shared,
 44+		state:  initState,
 45+		pages:  make([]tea.Model, 8),
 46+	}
 47+	return m
 48+}
 49+
 50+func (m *UI) updateActivePage(msg tea.Msg) tea.Cmd {
 51+	nm, cmd := m.pages[m.activePage].Update(msg)
 52+	m.pages[m.activePage] = nm
 53+	return cmd
 54+}
 55+
 56+func (m *UI) updateModels(msg tea.Msg) tea.Cmd {
 57+	cmds := []tea.Cmd{}
 58+	for i, page := range m.pages {
 59+		if page == nil {
 60+			continue
 61+		}
 62+		nm, cmd := page.Update(msg)
 63+		m.pages[i] = nm
 64+		cmds = append(cmds, cmd)
 65+	}
 66+	return tea.Batch(cmds...)
 67+}
 68+
 69+func (m *UI) Init() tea.Cmd {
 70+	user, err := findUser(m.shared)
 71+	if err != nil {
 72+		wish.Errorln(m.shared.Session, err)
 73+		return tea.Quit
 74+	}
 75+	m.shared.User = user
 76+
 77+	ff, _ := findPlusFeatureFlag(m.shared)
 78+	m.shared.PlusFeatureFlag = ff
 79+
 80+	m.pages[pages.MenuPage] = menu.NewModel(m.shared)
 81+	m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
 82+	m.pages[pages.CreatePubkeyPage] = createkey.NewModel(m.shared)
 83+	m.pages[pages.CreateTokenPage] = createtoken.NewModel(m.shared)
 84+	m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
 85+	m.pages[pages.PubkeysPage] = pubkeys.NewModel(m.shared)
 86+	m.pages[pages.TokensPage] = tokens.NewModel(m.shared)
 87+	m.pages[pages.NotificationsPage] = notifications.NewModel(m.shared)
 88+	m.pages[pages.PlusPage] = plus.NewModel(m.shared)
 89+	if m.shared.User == nil {
 90+		m.activePage = pages.CreateAccountPage
 91+	} else {
 92+		m.activePage = pages.MenuPage
 93+	}
 94+	m.state = readyState
 95+	return nil
 96+}
 97+
 98+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 99+	var cmds []tea.Cmd
100+
101+	switch msg := msg.(type) {
102+	case tea.WindowSizeMsg:
103+		m.shared.Width = msg.Width
104+		m.shared.Height = msg.Height
105+		return m, m.updateModels(msg)
106+
107+	case tea.KeyMsg:
108+		switch msg.Type {
109+		case tea.KeyCtrlC:
110+			m.shared.Dbpool.Close()
111+			return m, tea.Quit
112+		}
113+
114+	case pages.NavigateMsg:
115+		// send message to the active page so it can teardown
116+		// and reset itself
117+		cmds = append(cmds, m.updateActivePage(msg))
118+		m.activePage = msg.Page
119+
120+	// user created account
121+	case createaccount.CreateAccountMsg:
122+		return m, m.Init()
123+
124+	case menu.MenuChoiceMsg:
125+		switch msg.MenuChoice {
126+		case menu.KeysChoice:
127+			m.activePage = pages.PubkeysPage
128+		case menu.TokensChoice:
129+			m.activePage = pages.TokensPage
130+		case menu.PlusChoice:
131+			m.activePage = pages.PlusPage
132+		case menu.NotificationsChoice:
133+			m.activePage = pages.NotificationsPage
134+		case menu.ChatChoice:
135+			return m, LoadChat(m.shared)
136+		case menu.ExitChoice:
137+			m.shared.Dbpool.Close()
138+			return m, tea.Quit
139+		}
140+
141+		cmds = append(cmds, m.pages[m.activePage].Init())
142+	}
143+
144+	cmd := m.updateActivePage(msg)
145+	cmds = append(cmds, cmd)
146+
147+	return m, tea.Batch(cmds...)
148+}
149+
150+func (m *UI) View() string {
151+	w := m.shared.Width - m.shared.Styles.App.GetHorizontalFrameSize()
152+	s := m.shared.Styles.Logo.SetString("pico.sh").String() + "\n\n"
153+	if m.pages[m.activePage] != nil {
154+		s += m.pages[m.activePage].View()
155+	}
156+	str := wrap.String(wordwrap.String(s, w), w)
157+	return m.shared.Styles.App.Render(str)
158+}
159+
160+func findUser(shrd common.SharedModel) (*db.User, error) {
161+	logger := shrd.Cfg.Logger
162+	var user *db.User
163+	usr := shrd.Session.User()
164+
165+	key, err := shared.KeyForKeyText(shrd.Session.PublicKey())
166+	if err != nil {
167+		return nil, err
168+	}
169+
170+	user, err = shrd.Dbpool.FindUserForKey(usr, key)
171+	if err != nil {
172+		logger.Error("no user found for public key", "err", err.Error())
173+		// we only want to throw an error for specific cases
174+		if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
175+			return nil, err
176+		}
177+		// no user and not error indicates we need to create an account
178+		return nil, nil
179+	}
180+
181+	return user, nil
182+}
183+
184+func findPlusFeatureFlag(shrd common.SharedModel) (*db.FeatureFlag, error) {
185+	if shrd.User == nil {
186+		return nil, nil
187+	}
188+
189+	ff, err := shrd.Dbpool.FindFeatureForUser(shrd.User.ID, "plus")
190+	if err != nil {
191+		return nil, err
192+	}
193+
194+	return ff, nil
195+}