feat(app): pivot from chat UI to order console with menu/order protocol
Client (main.go):
- Replace chat viewport/textarea with a simple order console UI using Bubble Tea + Huh forms
- Add types and messages for connection, menu loading, order submission, and status
- Implement connectCmd with short greeting drain; status updates on connect
- Implement fetchMenuCmd: send "MENU", parse single-line JSON into []menuItem
- Implement submitOrderCmd: send "ORDER <json>", read single-line ack
- Build interactive form (name, menu select, quantity, confirm) with validation
- Add model state for host, status/loading/error, lastOrder, and form fields
- Keyboard controls: n (new order), r (reconnect), q/esc/ctrl+c (quit)
- Simplify program start (remove pre-connect path); alt-screen only
- Remove chat colorization/regex, viewport, and textarea logic
Server (server.go):
- Define menuItem and order structs; add a defaultMenu
- Extend protocol:
- "MENU" returns single-line JSON array of menu items
- "ORDER <json>" validates name/item/quantity (with lenient quantity parsing)
- Acknowledge with "OK" or send "[error] ..." lines
- Optionally broadcast "[order] ..." to chat for visibility
- Keep existing chat behavior; integrate new commands alongside it
Notes:
- Server logs ORDER parsing and continues to support chat commands
- Error handling surfaces server-side errors to the client status/messages
This commit is contained in:
parent
d8f5c27bd4
commit
623bc075eb
559
main.go
559
main.go
@ -2,235 +2,412 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"strconv"
|
||||||
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textarea"
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// colorizeLine applies ANSI styling to usernames based on 6-hex-digit id from the server.
|
// menuItem represents one option in the server-provided menu.
|
||||||
func colorizeLine(s string) string {
|
// Expected JSON (one line): [{"id":"latte","name":"Caffè Latte"}, ...]
|
||||||
reChat := regexp.MustCompile(`^(.+?) \(([0-9a-fA-F]{6})\):[ \t]*(.*)$`)
|
|
||||||
if m := reChat.FindStringSubmatch(s); m != nil {
|
|
||||||
id := strings.ToLower(m[2])
|
|
||||||
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
|
||||||
name := nameStyle.Render(m[1])
|
|
||||||
rest := strings.TrimSpace(m[3])
|
|
||||||
return fmt.Sprintf("%s: %s", name, rest)
|
|
||||||
}
|
|
||||||
|
|
||||||
reJoinLeave := regexp.MustCompile(`^\[(join|leave)\] (.+?) \(([0-9a-fA-F]{6})\)$`)
|
// order represents the payload we submit back to the server.
|
||||||
if m := reJoinLeave.FindStringSubmatch(s); m != nil {
|
|
||||||
id := strings.ToLower(m[3])
|
|
||||||
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
|
||||||
uname := nameStyle.Render(m[2])
|
|
||||||
return fmt.Sprintf("[%s] %s", m[1], uname)
|
|
||||||
}
|
|
||||||
|
|
||||||
reRename := regexp.MustCompile(`^\[rename\] (.+?) \(([0-9a-fA-F]{6})\) -> (.+)$`)
|
// messages used by Bubble Tea
|
||||||
if m := reRename.FindStringSubmatch(s); m != nil {
|
type (
|
||||||
id := strings.ToLower(m[2])
|
connectedMsg struct{ conn net.Conn }
|
||||||
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
menuLoadedMsg struct {
|
||||||
oldN := nameStyle.Render(m[1])
|
items []menuItem
|
||||||
newN := nameStyle.Render(m[3])
|
err error
|
||||||
return fmt.Sprintf("[rename] %s -> %s", oldN, newN)
|
|
||||||
}
|
}
|
||||||
|
orderSubmittedMsg struct {
|
||||||
reWelcome := regexp.MustCompile(`^Welcome (.+?) \(([0-9a-fA-F]{6})\)$`)
|
ack string
|
||||||
if m := reWelcome.FindStringSubmatch(s); m != nil {
|
err error
|
||||||
id := strings.ToLower(m[2])
|
|
||||||
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
|
||||||
uname := nameStyle.Render(m[1])
|
|
||||||
return fmt.Sprintf("Welcome %s", uname)
|
|
||||||
}
|
}
|
||||||
|
statusMsg string
|
||||||
|
)
|
||||||
|
|
||||||
return s
|
type FormFields struct {
|
||||||
|
name string
|
||||||
|
itemID string
|
||||||
|
quantityStr string
|
||||||
|
confirm bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type netMsg string
|
// model holds the TUI state.
|
||||||
type connectedMsg struct{ conn net.Conn }
|
|
||||||
type disconnectedMsg struct{}
|
|
||||||
type errorMsg struct{ err error }
|
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
vp viewport.Model
|
// connection
|
||||||
input textarea.Model
|
host string
|
||||||
messages []string
|
conn net.Conn
|
||||||
|
|
||||||
conn net.Conn
|
// UI
|
||||||
server string
|
title string
|
||||||
err error
|
status string
|
||||||
|
loading bool
|
||||||
|
err error
|
||||||
|
lastOrder *order
|
||||||
|
|
||||||
|
// form
|
||||||
|
form *huh.Form
|
||||||
|
formFields *FormFields
|
||||||
|
menu []menuItem
|
||||||
|
name string
|
||||||
|
itemID string
|
||||||
|
quantityStr string
|
||||||
|
confirm bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialModel(serverAddr string) model {
|
// initialModel creates a base model.
|
||||||
vp := viewport.New(80, 20)
|
func initialModel(host string) model {
|
||||||
vp.Style = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(0, 1).BorderForeground(lipgloss.Color("#bada55"))
|
|
||||||
|
|
||||||
ta := textarea.New()
|
|
||||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
||||||
ta.Placeholder = "Type message and press Enter..."
|
|
||||||
ta.Focus()
|
|
||||||
ta.Prompt = "┃ "
|
|
||||||
ta.CharLimit = 0
|
|
||||||
ta.SetHeight(2)
|
|
||||||
ta.ShowLineNumbers = false
|
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
vp: vp,
|
host: host,
|
||||||
input: ta,
|
title: "Order Console",
|
||||||
messages: []string{},
|
formFields: &FormFields{},
|
||||||
server: serverAddr,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
if m.conn != nil {
|
// Connect on startup
|
||||||
return tea.Batch(textarea.Blink, readLineCmd(m.conn))
|
return connectCmd(m.host)
|
||||||
}
|
|
||||||
return tea.Batch(textarea.Blink, connectCmd(m.server))
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectCmd(addr string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return netMsg(fmt.Sprintf("[error] connect: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return connectedMsg{conn: conn}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLineCmd(conn net.Conn) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
if conn == nil {
|
|
||||||
return disconnectedMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bufio.NewReader(conn)
|
|
||||||
line, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return disconnectedMsg{}
|
|
||||||
}
|
|
||||||
return netMsg(fmt.Sprintf("[error] read: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return netMsg(strings.TrimRight(line, "\r\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendCmd(conn net.Conn, text string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
if conn == nil {
|
|
||||||
return netMsg("[error] not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fmt.Fprintln(conn, text)
|
|
||||||
if err != nil {
|
|
||||||
return netMsg(fmt.Sprintf("[error] send: %v", err))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
// If a form is active, delegate to it first.
|
||||||
|
if m.form != nil {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
form, cmd := m.form.Update(msg)
|
||||||
|
|
||||||
|
if f, ok := form.(*huh.Form); ok {
|
||||||
|
m.form = f
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.form.State == huh.StateCompleted {
|
||||||
|
// Parse and submit order if confirmed.
|
||||||
|
qty, err := strconv.Atoi(strings.TrimSpace(m.formFields.quantityStr))
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
m.err = fmt.Errorf("invalid quantity: %v", m.formFields.quantityStr)
|
||||||
|
m.form = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
ord := &order{
|
||||||
|
Name: strings.TrimSpace(m.formFields.name),
|
||||||
|
ItemID: m.formFields.itemID,
|
||||||
|
Quantity: qty,
|
||||||
|
}
|
||||||
|
m.lastOrder = ord
|
||||||
|
m.form = nil
|
||||||
|
|
||||||
|
if m.formFields.confirm {
|
||||||
|
if m.conn == nil {
|
||||||
|
m.status = "Not connected. Unable to submit order."
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
m.loading = true
|
||||||
|
m.status = "Submitting order..."
|
||||||
|
return m, submitOrderCmd(m.conn, *ord)
|
||||||
|
}
|
||||||
|
m.status = "Order canceled."
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.form.State == huh.StateAborted {
|
||||||
|
m.status = "Order form aborted."
|
||||||
|
m.form = nil
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
case connectedMsg:
|
case connectedMsg:
|
||||||
m.conn = msg.conn
|
m.conn = msg.conn
|
||||||
m.messages = append(m.messages, "[connected]")
|
m.status = fmt.Sprintf("Connected to %s", m.host)
|
||||||
m.refreshViewport()
|
|
||||||
|
|
||||||
return m, readLineCmd(m.conn)
|
|
||||||
|
|
||||||
case disconnectedMsg:
|
|
||||||
m.messages = append(m.messages, "[disconnected]")
|
|
||||||
if m.conn != nil {
|
|
||||||
_ = m.conn.Close()
|
|
||||||
m.conn = nil
|
|
||||||
}
|
|
||||||
m.refreshViewport()
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case netMsg:
|
case menuLoadedMsg:
|
||||||
m.messages = append(m.messages, colorizeLine(string(msg)))
|
m.loading = false
|
||||||
m.refreshViewport()
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
if m.conn != nil && !strings.HasPrefix(string(msg), "[error] read") {
|
m.status = "Failed to load menu."
|
||||||
cmds = append(cmds, readLineCmd(m.conn))
|
return m, nil
|
||||||
}
|
}
|
||||||
|
m.err = nil
|
||||||
|
m.menu = msg.items
|
||||||
|
m.status = "Menu loaded."
|
||||||
|
// Open the form now that we have a menu.
|
||||||
|
m.form = m.buildForm()
|
||||||
|
return m, m.form.Init()
|
||||||
|
|
||||||
|
case orderSubmittedMsg:
|
||||||
|
m.loading = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = msg.err
|
||||||
|
m.status = "Order submission failed."
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
if msg.ack != "" {
|
||||||
|
m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack)
|
||||||
|
} else {
|
||||||
|
m.status = "Order submitted."
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case statusMsg:
|
||||||
|
m.status = string(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
switch msg.String() {
|
||||||
case tea.KeyCtrlC, tea.KeyEsc:
|
case "q", "ctrl+c", "esc":
|
||||||
if m.conn != nil {
|
if m.conn != nil {
|
||||||
_ = m.conn.Close()
|
_ = m.conn.Close()
|
||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case tea.KeyEnter:
|
case "r":
|
||||||
text := m.input.Value()
|
// Reconnect
|
||||||
if text != "" {
|
if m.conn != nil {
|
||||||
cmds = append(cmds, sendCmd(m.conn, text))
|
_ = m.conn.Close()
|
||||||
m.input.SetValue("")
|
m.conn = nil
|
||||||
m.refreshViewport()
|
|
||||||
}
|
}
|
||||||
return m, tea.Batch(cmds...)
|
m.status = "Reconnecting..."
|
||||||
|
return m, connectCmd(m.host)
|
||||||
|
case "n":
|
||||||
|
// Start a new order
|
||||||
|
if m.loading || m.form != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.conn == nil {
|
||||||
|
m.status = "Not connected. Press 'r' to reconnect."
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
m.loading = true
|
||||||
|
m.status = "Loading menu..."
|
||||||
|
return m, fetchMenuCmd(m.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
// Allocate space: viewport above, input below
|
// No dynamic layout needed, handled in View.
|
||||||
m.vp.Width = msg.Width
|
|
||||||
m.vp.Height = msg.Height - 4
|
|
||||||
m.input.SetWidth(msg.Width - 2)
|
|
||||||
m.refreshViewport()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let textarea handle remaining keys
|
return m, nil
|
||||||
var cmd tea.Cmd
|
|
||||||
m.input, cmd = m.input.Update(msg)
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *model) refreshViewport() {
|
|
||||||
contentWidth := m.vp.Width - m.vp.Style.GetHorizontalFrameSize()
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for _, line := range m.messages {
|
|
||||||
wrapped := lipgloss.NewStyle().Width(contentWidth).Render(line)
|
|
||||||
b.WriteString(wrapped)
|
|
||||||
if !strings.HasSuffix(wrapped, "\n") {
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.vp.SetContent(b.String())
|
|
||||||
m.vp.GotoBottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
header := lipgloss.NewStyle().Width(m.vp.Width).Padding(0, 1).Align(lipgloss.Center).Foreground(lipgloss.Color("241")).Bold(true).Render(m.server)
|
// Basic centered title and instructions
|
||||||
return lipgloss.JoinVertical(
|
w := lipgloss.NewStyle().Width(80)
|
||||||
lipgloss.Left,
|
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Render(m.title)
|
||||||
header,
|
host := lipgloss.NewStyle().Faint(true).Render(m.host)
|
||||||
m.vp.View(),
|
|
||||||
m.input.View(),
|
lines := []string{
|
||||||
|
w.Align(lipgloss.Center).Render(title),
|
||||||
|
w.Align(lipgloss.Center).Render(host),
|
||||||
|
"",
|
||||||
|
"Controls:",
|
||||||
|
"- n: New order",
|
||||||
|
"- r: Reconnect",
|
||||||
|
"- q: Quit",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.loading {
|
||||||
|
lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading..."))
|
||||||
|
} else if m.status != "" {
|
||||||
|
lines = append(lines, "Status: "+m.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(fmt.Sprintf("Error: %v", m.err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.lastOrder != nil {
|
||||||
|
lines = append(lines, "", "Last order:")
|
||||||
|
lines = append(lines, fmt.Sprintf("- Name: %s", m.lastOrder.Name))
|
||||||
|
// Map selected item label for display
|
||||||
|
var label string
|
||||||
|
for _, it := range m.menu {
|
||||||
|
if it.ID == m.lastOrder.ItemID {
|
||||||
|
label = it.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("- Item: %s (%s)", label, m.lastOrder.ItemID))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fmt.Sprintf("- Item: %s", m.lastOrder.ItemID))
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- Quantity: %d", m.lastOrder.Quantity))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.form != nil {
|
||||||
|
// When the form is active, render only the form (full screen)
|
||||||
|
return m.form.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm.
|
||||||
|
func (m *model) buildForm() *huh.Form {
|
||||||
|
// Convert menu to huh options
|
||||||
|
opts := make([]huh.Option[string], 0, len(m.menu))
|
||||||
|
for _, it := range m.menu {
|
||||||
|
opts = append(opts, huh.NewOption(it.Name, it.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset bound fields for a fresh form
|
||||||
|
m.formFields.name = ""
|
||||||
|
m.formFields.itemID = ""
|
||||||
|
m.formFields.quantityStr = ""
|
||||||
|
m.formFields.confirm = false
|
||||||
|
|
||||||
|
f := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Your name").
|
||||||
|
Prompt("> ").
|
||||||
|
Placeholder("Jane Doe").
|
||||||
|
Value(&m.formFields.name).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Menu item").
|
||||||
|
Options(opts...).
|
||||||
|
Value(&m.formFields.itemID).
|
||||||
|
Validate(func(v string) error {
|
||||||
|
if v == "" {
|
||||||
|
return errors.New("please select a menu item")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Quantity").
|
||||||
|
Prompt("> ").
|
||||||
|
Placeholder("1").
|
||||||
|
Value(&m.formFields.quantityStr).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return errors.New("enter a positive integer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Place order?").
|
||||||
|
Affirmative("Yes").
|
||||||
|
Negative("No").
|
||||||
|
Value(&m.formFields.confirm),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectCmd connects to the TCP server.
|
||||||
|
func connectCmd(addr string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return statusMsg(fmt.Sprintf("Connect failed: %v", err))
|
||||||
|
}
|
||||||
|
// Try to read up to two greeting lines with short deadline (optional).
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, err := br.ReadString('\n'); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
return connectedMsg{conn: conn}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMenuCmd asks the server for a menu via the TCP connection.
|
||||||
|
// Protocol (proposed):
|
||||||
|
// - client: "MENU\n"
|
||||||
|
// - server: single line JSON array: [{"id":"x","name":"..."}]\n
|
||||||
|
func fetchMenuCmd(conn net.Conn) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if conn == nil {
|
||||||
|
return menuLoadedMsg{err: errors.New("not connected")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
if _, err := fmt.Fprintln(conn, "MENU"); err != nil {
|
||||||
|
return menuLoadedMsg{err: fmt.Errorf("send MENU: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read single JSON line
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return menuLoadedMsg{err: fmt.Errorf("read MENU: %w", err)}
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
// If the server sent an error-ish line, surface it.
|
||||||
|
if strings.HasPrefix(line, "[error]") {
|
||||||
|
return menuLoadedMsg{err: fmt.Errorf("server: %s", line)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []menuItem
|
||||||
|
if err := json.Unmarshal([]byte(line), &items); err != nil {
|
||||||
|
return menuLoadedMsg{err: fmt.Errorf("invalid menu JSON: %w", err)}
|
||||||
|
}
|
||||||
|
return menuLoadedMsg{items: items}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitOrderCmd sends the order over TCP.
|
||||||
|
// Protocol (proposed):
|
||||||
|
// - client: "ORDER <json>\n"
|
||||||
|
// - server: a single line acknowledgement (freeform), e.g. "OK\n"
|
||||||
|
func submitOrderCmd(conn net.Conn, ord order) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if conn == nil {
|
||||||
|
return orderSubmittedMsg{err: errors.New("not connected")}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(ord)
|
||||||
|
if err != nil {
|
||||||
|
return orderSubmittedMsg{err: fmt.Errorf("marshal order: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(conn, "ORDER %s\n", string(b)); err != nil {
|
||||||
|
return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read single-line ack
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)}
|
||||||
|
}
|
||||||
|
return orderSubmittedMsg{ack: strings.TrimRight(line, "\r\n")}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -238,11 +415,11 @@ func main() {
|
|||||||
host string
|
host string
|
||||||
serverOnly bool
|
serverOnly bool
|
||||||
)
|
)
|
||||||
|
|
||||||
flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on")
|
flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on")
|
||||||
flag.BoolVar(&serverOnly, "server", false, "run only the server")
|
flag.BoolVar(&serverOnly, "server", false, "run only the server")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// If requested, start the TCP server (chat server as-is).
|
||||||
if serverOnly {
|
if serverOnly {
|
||||||
if err := startTCPServer(host); err != nil {
|
if err := startTCPServer(host); err != nil {
|
||||||
fmt.Println("Server error:", err)
|
fmt.Println("Server error:", err)
|
||||||
@ -250,34 +427,10 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
// Client TUI
|
||||||
|
|
||||||
// Pre-connect and read initial welcome/instruction before starting UI
|
|
||||||
var preConn net.Conn
|
|
||||||
var preMsgs []string
|
|
||||||
if conn, err := net.DialTimeout("tcp", host, 3*time.Second); err == nil {
|
|
||||||
preConn = conn
|
|
||||||
// Read up to two initial lines with a short deadline
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
|
||||||
r := bufio.NewReader(conn)
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
line, err := r.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
preMsgs = append(preMsgs, strings.TrimRight(line, "\r\n"))
|
|
||||||
}
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
|
||||||
}
|
|
||||||
|
|
||||||
m := initialModel(host)
|
m := initialModel(host)
|
||||||
if preConn != nil {
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
m.conn = preConn
|
|
||||||
m.messages = append(m.messages, preMsgs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Println("Error:", err)
|
fmt.Println("error:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
server.go
92
server.go
@ -2,15 +2,37 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// menuItem is the structure returned to clients for MENU.
|
||||||
|
type menuItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultMenu is a simple, static menu. Replace or make dynamic as needed.
|
||||||
|
var defaultMenu = []menuItem{
|
||||||
|
{ID: "latte", Name: "Caffè Latte"},
|
||||||
|
{ID: "cap", Name: "Cappuccino"},
|
||||||
|
{ID: "esp", Name: "Espresso"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// order is the structure the server expects for ORDER.
|
||||||
|
type order struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
// broadcast represents a line to send to all connections with the ability
|
// broadcast represents a line to send to all connections with the ability
|
||||||
// to exclude a single connection (e.g., exclude self on join).
|
// to exclude a single connection (e.g., exclude self on join).
|
||||||
type broadcast struct {
|
type broadcast struct {
|
||||||
@ -129,7 +151,75 @@ func handleConn(h *Hub, c net.Conn) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commands
|
// New protocol commands:
|
||||||
|
// MENU -> server returns single-line JSON array of menuItem
|
||||||
|
if strings.EqualFold(line, "MENU") {
|
||||||
|
b, err := json.Marshal(defaultMenu)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(c, `[error] failed to encode menu`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintln(c, string(b))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER <json> -> server validates and replies with a single-line ack
|
||||||
|
if strings.HasPrefix(line, "ORDER") {
|
||||||
|
raw := strings.TrimSpace(line[len("ORDER"):])
|
||||||
|
var ord order
|
||||||
|
if err := json.Unmarshal([]byte(raw), &ord); err != nil {
|
||||||
|
fmt.Fprintln(c, "[error] invalid order json")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ord.Name = strings.TrimSpace(ord.Name)
|
||||||
|
log.Printf("ORDER parsed: name=%q itemId=%q qty=%d", ord.Name, ord.ItemID, ord.Quantity)
|
||||||
|
if ord.Name == "" {
|
||||||
|
fmt.Fprintln(c, "[error] missing name")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Fallback handling: accept numeric strings or floats for quantity
|
||||||
|
if ord.Quantity <= 0 {
|
||||||
|
var generic map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &generic); err == nil {
|
||||||
|
if v, ok := generic["quantity"]; ok {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
||||||
|
ord.Quantity = n
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
ord.Quantity = int(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ord.Quantity <= 0 {
|
||||||
|
fmt.Fprintln(c, "[error] invalid quantity")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chosen *menuItem
|
||||||
|
for i := range defaultMenu {
|
||||||
|
if defaultMenu[i].ID == ord.ItemID {
|
||||||
|
chosen = &defaultMenu[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
fmt.Fprintln(c, "[error] unknown item")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: broadcast to chat listeners for visibility
|
||||||
|
h.msgCh <- broadcast{
|
||||||
|
text: fmt.Sprintf("[order] %s (%s) ordered %d × %s", username, id, ord.Quantity, chosen.Name),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledge to the ordering client
|
||||||
|
fmt.Fprintln(c, "OK")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat commands
|
||||||
if line == "/quit" {
|
if line == "/quit" {
|
||||||
break // unified leave handling below
|
break // unified leave handling below
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user