package main import ( "context" "fmt" "net" "os" "os/signal" "strings" "syscall" "time" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" "github.com/charmbracelet/wish/activeterm" "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/wish/logging" "github.com/muesli/termenv" ) const ( host = "0.0.0.0" port = "23234" ) // Contains a wish server and the list of running programs. type app struct { *ssh.Server progs []*tea.Program } // Dispatches a message to all running programs. func (a *app) send(msg tea.Msg) { for _, p := range a.progs { go p.Send(msg) } } func newApp() *app { a := new(app) s, err := wish.NewServer( wish.WithAddress(net.JoinHostPort(host, port)), wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithMiddleware( bubbletea.MiddlewareWithProgramHandler(a.ProgramHandler, termenv.ANSI256), activeterm.Middleware(), logging.Middleware(), ), ) if err != nil { log.Error("Could not start server", "error", err) } a.Server = s return a } func (a *app) Start() { var err error done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.Info("Starting SSH server", "host", host, "port", port) go func() { if err = a.ListenAndServe(); err != nil { log.Error("Could not start server", "error", err) done <- nil } }() <-done log.Info("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := a.Shutdown(ctx); err != nil { log.Error("Could not stop server", "error", err) } } func (a *app) ProgramHandler(s ssh.Session) *tea.Program { model := initialModel() model.app = a model.id = s.User() p := tea.NewProgram(model, bubbletea.MakeOptions(s)...) a.progs = append(a.progs, p) return p } func main() { app := newApp() app.Start() } type ( errMsg error chatMsg struct { id string text string } ) type model struct { *app viewport viewport.Model messages []string id string textarea textarea.Model senderStyle lipgloss.Style err error } func initialModel() model { ta := textarea.New() ta.Placeholder = "Send a message..." ta.Focus() ta.Prompt = "┃ " ta.CharLimit = 280 ta.SetWidth(30) ta.SetHeight(2) // Remove cursor line styling ta.FocusedStyle.CursorLine = lipgloss.NewStyle() ta.ShowLineNumbers = false vp := viewport.New(30, 5) vp.SetContent(`Welcome! Type a message and press Enter to send.`) ta.KeyMap.InsertNewline.SetEnabled(false) return model{ textarea: ta, messages: []string{}, viewport: vp, senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), err: nil, } } func (m model) Init() tea.Cmd { return textarea.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( tiCmd tea.Cmd vpCmd tea.Cmd ) m.textarea, tiCmd = m.textarea.Update(msg) m.viewport, vpCmd = m.viewport.Update(msg) switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit case tea.KeyEnter: m.app.send(chatMsg{ id: m.id, text: m.textarea.Value(), }) m.textarea.Reset() } case chatMsg: m.messages = append(m.messages, m.senderStyle.Render(msg.id)+": "+msg.text) m.viewport.SetContent(strings.Join(m.messages, "\n")) m.viewport.GotoBottom() // We handle errors just like any other message case errMsg: m.err = msg return m, nil } return m, tea.Batch(tiCmd, vpCmd) } func (m model) View() string { return fmt.Sprintf( "%s\n\n%s", m.viewport.View(), m.textarea.View(), ) + "\n\n" }