tuichat/main.go
2025-01-10 01:26:41 -06:00

195 lines
3.8 KiB
Go

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"
}