Send and read plain-text messages

Adds LocalSend-compatible text messaging alongside file transfer.

Wire format (matches the LocalSend app): a message is a single "file" with
fileType "text/plain" whose content rides in the prepare-upload `preview`
field. The receiver returns an empty token set, so nothing is uploaded — the
text is read straight from the preview.

- client: SendMessage builds that single-file prepare-upload (no body upload).
- server: detect a message (one text file with non-empty preview), surface it
  on a new Messages() channel instead of saving a file, and respond with an
  empty file set. Messages bypass the accept prompt (auto-received); the PIN
  gate still applies.
- app: bridge the server's messages channel to the TUI as MessageMsg.
- tui: a new Messages tab lists received messages (enter to read full, d to
  delete); press `m` on a device to compose and send one. Incoming messages
  show a footer notice.

Tests: end-to-end send→receive (text intact, sender preserved, nothing written
to disk) and unit coverage of the message-detection rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-27 21:40:40 +01:00
parent 6e7aac090c
commit 7ab3f905ee
9 changed files with 467 additions and 26 deletions

View file

@ -15,6 +15,8 @@ LocalSend mobile and desktop apps on the same LAN, including their default
- **Send** — pick a peer, stage files (or whole folders) with a built-in file - **Send** — pick a peer, stage files (or whole folders) with a built-in file
picker, and upload them with progress, rate and ETA. Folders are sent picker, and upload them with progress, rate and ETA. Folders are sent
recursively with their structure preserved on the receiver. recursively with their structure preserved on the receiver.
- **Messages** — send a plain-text message to a peer (LocalSend-compatible) and
read messages others send you in a dedicated Messages tab.
- **Manage** — browse the receive folder, mark received files (or whole folders) - **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt. and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the - **HTTPS** — generates a self-signed certificate whose fingerprint matches the
@ -91,11 +93,13 @@ omarchy-send --auto-accept --pin 2468
### Keys ### Keys
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings - `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter - Peers: `enter` send to the selected peer · `m` message the selected peer · `r` refresh · `/` filter
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back - Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
- Incoming prompt: `y` accept · `n` reject - Incoming prompt: `y` accept · `n` reject
- Transfers: `c` clear finished - Transfers: `c` clear finished
- Messages: `enter` read the full message · `d` delete it (incoming messages
arrive automatically, with a footer notice)
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one - Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept - Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept

View file

@ -28,6 +28,7 @@ type controller struct {
func (c controller) Announce() { c.disc.Announce() } func (c controller) Announce() { c.disc.Announce() }
func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) } func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) }
func (c controller) SendMessage(p discovery.Peer, text string) { c.sender.SendMessage(p, text) }
func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) } func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
@ -113,7 +114,7 @@ func main() {
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen()) p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send) app.BridgeDiscovery(ctx, disc.Events(), p.Send)
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), p.Send) app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), srv.Messages(), p.Send)
app.BridgeTransfers(ctx, sender.Events(), p.Send) app.BridgeTransfers(ctx, sender.Events(), p.Send)
disc.Announce() // announce immediately so we appear without waiting a tick disc.Announce() // announce immediately so we appear without waiting a tick

View file

@ -26,9 +26,12 @@ type IncomingMsg struct{ Req server.AcceptRequest }
// TransferMsg reports progress/lifecycle of a transfer (either direction). // TransferMsg reports progress/lifecycle of a transfer (either direction).
type TransferMsg struct{ Ev transfer.Event } type TransferMsg struct{ Ev transfer.Event }
// BridgeServer forwards the server's accept requests and transfer events to the // MessageMsg is delivered when a peer sends us a plain-text message.
// Tea program until ctx is cancelled. type MessageMsg struct{ Msg server.ReceivedMessage }
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, send func(tea.Msg)) {
// BridgeServer forwards the server's accept requests, transfer events, and
// received messages to the Tea program until ctx is cancelled.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg)) {
go func() { go func() {
for { for {
select { select {
@ -38,6 +41,8 @@ func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, tran
send(IncomingMsg{Req: req}) send(IncomingMsg{Req: req})
case ev := <-transfers: case ev := <-transfers:
send(TransferMsg{Ev: ev}) send(TransferMsg{Ev: ev})
case m := <-messages:
send(MessageMsg{Msg: m})
} }
} }
}() }()

View file

@ -94,6 +94,33 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
go s.send(peer, paths, pin) go s.send(peer, paths, pin)
} }
// SendMessage sends a plain-text message to peer (LocalSend "send message":
// one text file whose content rides in the preview field, so nothing is
// uploaded). Errors are reported on Events() as an outgoing Error.
func (s *Sender) SendMessage(peer discovery.Peer, text string) {
go s.sendMessage(peer, text)
}
func (s *Sender) sendMessage(peer discovery.Peer, text string) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
id := randID()
files := map[string]protocol.FileMetadata{
id: {
ID: id,
FileName: "message.txt",
Size: int64(len(text)),
FileType: "text/plain",
Preview: text,
},
}
if _, err := s.prepareUpload(ctx, s.url(peer), files, ""); err != nil {
dbg.Logf("send message to %s failed: %v", peer.IP, err)
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
}
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// A new transfer to a peer supersedes any still-running one to the same // A new transfer to a peer supersedes any still-running one to the same
// peer: cancel it so a half-finished old batch can't carry on once the user // peer: cancel it so a half-finished old batch can't carry on once the user

View file

@ -0,0 +1,54 @@
package client
import (
"context"
"os"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
)
// A sent message must arrive on the receiver's Messages channel with its text
// and sender intact, and must NOT be written to disk as a file.
func TestSendMessageEndToEnd(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53990, Protocol: "http",
}
// AutoAccept is false on purpose: messages must bypass the accept prompt.
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
sender.SendMessage(peer, "hello over the wire\nsecond line")
select {
case m := <-srv.Messages():
if m.Text != "hello over the wire\nsecond line" {
t.Fatalf("message text = %q", m.Text)
}
if m.From != "sender" {
t.Errorf("message from = %q, want \"sender\"", m.From)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the message")
}
// A message is not a file: nothing should land in the receive dir.
if entries, _ := os.ReadDir(recvDir); len(entries) != 0 {
t.Fatalf("message should not be saved; found %d entries in receive dir", len(entries))
}
}

View file

@ -0,0 +1,57 @@
package server
import (
"testing"
"omarchy-send/internal/protocol"
)
func TestMessageOf(t *testing.T) {
cases := []struct {
name string
files map[string]protocol.FileMetadata
wantOK bool
wantTxt string
}{
{
name: "text mime with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: "hi"}},
wantOK: true,
wantTxt: "hi",
},
{
name: "bare text enum with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text", Preview: "yo"}},
wantOK: true, wantTxt: "yo",
},
{
name: "text file without preview is a real file, not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: ""}},
wantOK: false,
},
{
name: "non-text with preview is not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "image/jpeg", Preview: "data"}},
wantOK: false,
},
{
name: "two files is never a message",
files: map[string]protocol.FileMetadata{
"a": {FileType: "text/plain", Preview: "hi"},
"b": {FileType: "text/plain", Preview: "bye"},
},
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
txt, ok := messageOf(c.files)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v", ok, c.wantOK)
}
if ok && txt != c.wantTxt {
t.Fatalf("text = %q, want %q", txt, c.wantTxt)
}
})
}
}

View file

@ -51,6 +51,15 @@ type Server struct {
accepts chan AcceptRequest accepts chan AcceptRequest
transfers chan transfer.Event transfers chan transfer.Event
messages chan ReceivedMessage
}
// ReceivedMessage is a plain-text message received from a peer (LocalSend
// "send message": a single text file whose content rides in the preview field).
type ReceivedMessage struct {
From string
Text string
Time time.Time
} }
// New returns a Server from the given options. // New returns a Server from the given options.
@ -63,6 +72,7 @@ func New(opts Options) *Server {
sessions: newSessionStore(), sessions: newSessionStore(),
accepts: make(chan AcceptRequest, 8), accepts: make(chan AcceptRequest, 8),
transfers: make(chan transfer.Event, 256), transfers: make(chan transfer.Event, 256),
messages: make(chan ReceivedMessage, 32),
} }
s.autoAccept.Store(opts.AutoAccept) s.autoAccept.Store(opts.AutoAccept)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -122,6 +132,9 @@ func (s *Server) Accepts() <-chan AcceptRequest { return s.accepts }
// Transfers returns the channel of incoming-transfer progress events. // Transfers returns the channel of incoming-transfer progress events.
func (s *Server) Transfers() <-chan transfer.Event { return s.transfers } func (s *Server) Transfers() <-chan transfer.Event { return s.transfers }
// Messages returns the channel of received plain-text messages.
func (s *Server) Messages() <-chan ReceivedMessage { return s.messages }
// Start binds the listener and serves in the background until ctx is cancelled. // Start binds the listener and serves in the background until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
ln, err := net.Listen("tcp", s.http.Addr) ln, err := net.Listen("tcp", s.http.Addr)
@ -185,6 +198,15 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// A "message" is a single text file whose content rides in the preview
// field (LocalSend convention). It's received in full right here — surface
// it and return an empty file set so the sender uploads nothing.
if text, ok := messageOf(req.Files); ok {
s.emitMessage(ReceivedMessage{From: req.Info.Alias, Text: text, Time: time.Now()})
writeJSON(w, protocol.PrepareUploadResponse{SessionID: randToken(), Files: map[string]string{}})
return
}
if !s.askAccept(req, clientIP(r)) { if !s.askAccept(req, clientIP(r)) {
http.Error(w, "rejected", http.StatusForbidden) http.Error(w, "rejected", http.StatusForbidden)
return return
@ -194,6 +216,35 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens}) writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens})
} }
// messageOf reports whether files represents a plain-text message (exactly one
// text file with non-empty preview) and returns the message text.
func messageOf(files map[string]protocol.FileMetadata) (string, bool) {
if len(files) != 1 {
return "", false
}
for _, f := range files {
if f.Preview != "" && isTextType(f.FileType) {
return f.Preview, true
}
}
return "", false
}
// isTextType matches both the MIME form ("text/plain") that LocalSend sends and
// the bare enum form ("text") older clients may use.
func isTextType(fileType string) bool {
return fileType == "text" || strings.HasPrefix(fileType, "text/")
}
// emitMessage delivers a received message without blocking the HTTP handler.
func (s *Server) emitMessage(m ReceivedMessage) {
dbg.Logf("received message from %q: %q", m.From, m.Text)
select {
case s.messages <- m:
default:
}
}
// askAccept honours auto-accept, or raises an AcceptRequest and blocks for the // askAccept honours auto-accept, or raises an AcceptRequest and blocks for the
// user's decision (with a timeout so a never-answered prompt can't wedge a // user's decision (with a timeout so a never-answered prompt can't wedge a
// peer's HTTP connection forever). // peer's HTTP connection forever).

View file

@ -32,6 +32,7 @@ import (
type Controller interface { type Controller interface {
Announce() Announce()
Send(peer discovery.Peer, paths []string, pin string) Send(peer discovery.Peer, paths []string, pin string)
SendMessage(peer discovery.Peer, text string)
SetAutoAccept(bool) SetAutoAccept(bool)
SetAlias(string) SetAlias(string)
SetReceiveDir(string) SetReceiveDir(string)
@ -44,10 +45,11 @@ const (
screenPeers screen = iota screenPeers screen = iota
screenTransfers screenTransfers
screenManage screenManage
screenMessages
screenSettings screenSettings
screenPicker // entered from Peers; not part of the tab rotation screenPicker // entered from Peers; not part of the tab rotation
tabCount = 4 // Devices · Transfers · Manage · Settings tabCount = 5 // Devices · Transfers · Manage · Messages · Settings
) )
// xfer is one row in the transfers view. // xfer is one row in the transfers view.
@ -106,6 +108,15 @@ type Model struct {
sendPeer *discovery.Peer sendPeer *discovery.Peer
sendPaths []string sendPaths []string
// Messages tab + compose modal.
msgList list.Model
messages []server.ReceivedMessage
composing bool
composeInput textinput.Model
composeTo *discovery.Peer
readingMsg *server.ReceivedMessage // non-nil while reading a message full-screen
notice string // transient footer flash (e.g. "message sent")
width, height int width, height int
quitting bool quitting bool
} }
@ -125,6 +136,11 @@ func New(cfg config.Config, ctrl Controller) Model {
fl.SetShowHelp(false) fl.SetShowHelp(false)
fl.SetShowStatusBar(false) fl.SetShowStatusBar(false)
ml := list.New(nil, msgDelegate{}, 0, 0)
ml.SetShowTitle(false)
ml.SetShowHelp(false)
ml.SetShowStatusBar(false)
fp := filepicker.New() fp := filepicker.New()
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
fp.CurrentDirectory = home fp.CurrentDirectory = home
@ -143,6 +159,11 @@ func New(cfg config.Config, ctrl Controller) Model {
pin.Placeholder = "PIN" pin.Placeholder = "PIN"
pin.CharLimit = 16 pin.CharLimit = 16
compose := textinput.New()
compose.Placeholder = "type a message"
compose.CharLimit = 2000
compose.Width = 48
mkInput := func(placeholder string, limit int) textinput.Model { mkInput := func(placeholder string, limit int) textinput.Model {
ti := textinput.New() ti := textinput.New()
ti.Placeholder = placeholder ti.Placeholder = placeholder
@ -171,6 +192,8 @@ func New(cfg config.Config, ctrl Controller) Model {
autoAccept: cfg.AutoAccept, autoAccept: cfg.AutoAccept,
pinInput: pin, pinInput: pin,
editInputs: editInputs, editInputs: editInputs,
msgList: ml,
composeInput: compose,
} }
} }
@ -187,6 +210,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.peerList.SetSize(iw, lh) m.peerList.SetSize(iw, lh)
m.fileList.SetSize(iw, lh) m.fileList.SetSize(iw, lh)
m.msgList.SetSize(iw, lh)
if ph := ih - 8; ph >= 3 { if ph := ih - 8; ph >= 3 {
m.picker.Height = ph m.picker.Height = ph
} else { } else {
@ -220,7 +244,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.applyTransfer(msg.Ev) m.applyTransfer(msg.Ev)
return m, nil return m, nil
case app.MessageMsg:
m.messages = append([]server.ReceivedMessage{msg.Msg}, m.messages...)
m.msgList.SetItems(m.msgItems())
m.notice = "✉ message from " + nonEmpty(msg.Msg.From, "a device")
return m, nil
case tea.KeyMsg: case tea.KeyMsg:
m.notice = "" // any key clears a transient footer notice
if m.composing {
return m.updateCompose(msg)
}
if m.readingMsg != nil {
if k := msg.String(); k == "esc" || k == "q" || k == "enter" {
m.readingMsg = nil
}
return m, nil
}
if m.showPin { if m.showPin {
return m.updatePin(msg) return m.updatePin(msg)
} }
@ -257,6 +297,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshManage() m.refreshManage()
return m, nil return m, nil
case "4": case "4":
m.screen = screenMessages
return m, nil
case "5":
m.screen = screenSettings m.screen = screenSettings
return m, nil return m, nil
case "tab": case "tab":
@ -293,6 +336,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == screenManage { if m.screen == screenManage {
return m.requestDelete() return m.requestDelete()
} }
if m.screen == screenMessages {
m.deleteSelectedMessage()
}
return m, nil
case "m":
// Compose a message to the highlighted device.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue("")
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil return m, nil
case "a": case "a":
if m.screen == screenManage { if m.screen == screenManage {
@ -328,6 +387,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.picker.Init() return m, m.picker.Init()
} }
} }
if m.screen == screenMessages {
if it, ok := m.msgList.SelectedItem().(msgItem); ok {
msg := it.m
m.readingMsg = &msg
}
return m, nil
}
} }
} }
@ -346,9 +412,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.fileList, cmd = m.fileList.Update(msg) m.fileList, cmd = m.fileList.Update(msg)
return m, cmd return m, cmd
} }
if m.pending == nil && m.screen == screenMessages {
var cmd tea.Cmd
m.msgList, cmd = m.msgList.Update(msg)
return m, cmd
}
return m, nil return m, nil
} }
// updateCompose handles the message-compose modal.
func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.composing = false
m.composeTo = nil
return m, nil
case "enter":
text := strings.TrimSpace(m.composeInput.Value())
if text != "" && m.composeTo != nil && m.ctrl != nil {
m.ctrl.SendMessage(*m.composeTo, text)
m.notice = "✉ message sent to " + m.composeTo.Info.Alias
}
m.composing = false
m.composeTo = nil
return m, nil
}
var cmd tea.Cmd
m.composeInput, cmd = m.composeInput.Update(msg)
return m, cmd
}
// deleteSelectedMessage drops the highlighted message from the list.
func (m *Model) deleteSelectedMessage() {
it, ok := m.msgList.SelectedItem().(msgItem)
if !ok {
return
}
for i := range m.messages {
if m.messages[i] == it.m {
m.messages = append(m.messages[:i], m.messages[i+1:]...)
break
}
}
m.msgList.SetItems(m.msgItems())
}
// updatePicker handles the file picker / staging screen. // updatePicker handles the file picker / staging screen.
func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
@ -588,6 +696,12 @@ func (m Model) View() string {
if m.confirmDel { if m.confirmDel {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView())) return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView()))
} }
if m.composing {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView()))
}
if m.readingMsg != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
}
cw, ih := innerDims(w, h) cw, ih := innerDims(w, h)
var body string var body string
@ -614,6 +728,12 @@ func (m Model) View() string {
default: default:
body = m.manageView() body = m.manageView()
} }
case screenMessages:
if len(m.messages) == 0 {
body, center = headerStyle.Render("No messages yet.\nSelect a device and press m to send one."), true
} else {
body = messageHeader() + "\n" + m.msgList.View()
}
case screenSettings: case screenSettings:
if m.editing { if m.editing {
body, center = m.settingsEditView(), true body, center = m.settingsEditView(), true
@ -667,7 +787,7 @@ func (m Model) tabBar() string {
} }
return tabInactiveStyle.Render(label) return tabInactiveStyle.Render(label)
} }
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Settings", screenSettings) return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Messages", screenMessages) + tab("Settings", screenSettings)
} }
func (m Model) pickerView() string { func (m Model) pickerView() string {
@ -967,6 +1087,12 @@ func (m Model) settingsEditView() string {
// footerText is the contextual help line shown at the bottom of the window. // footerText is the contextual help line shown at the bottom of the window.
func (m Model) footerText() string { func (m Model) footerText() string {
switch { switch {
case m.notice != "":
return m.notice
case m.composing:
return "enter send · esc cancel"
case m.readingMsg != nil:
return "esc/enter close"
case m.confirmDel: case m.confirmDel:
return "y/enter delete · n/esc cancel" return "y/enter delete · n/esc cancel"
case m.editing: case m.editing:
@ -974,13 +1100,15 @@ func (m Model) footerText() string {
case m.screen == screenPicker: case m.screen == screenPicker:
return "enter stage file · a add folder · backspace unstage · S send · esc back" return "enter stage file · a add folder · backspace unstage · S send · esc back"
case m.screen == screenPeers: case m.screen == screenPeers:
return "enter send-to · r refresh · / filter · 1-4 switch · q quit" return "enter send-to · m message · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenTransfers: case m.screen == screenTransfers:
return "c clear finished · 1-4 switch · q quit" return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage: case m.screen == screenManage:
return "space mark · a all · d delete · r refresh · / filter · 1-4 switch · q quit" return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenMessages:
return "enter read · d delete · 1-5 switch · q quit"
case m.screen == screenSettings: case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 1-4 switch · q quit" return "e edit · a auto-accept · i icons · 1-5 switch · q quit"
} }
return "q quit" return "q quit"
} }

View file

@ -0,0 +1,114 @@
package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"omarchy-send/internal/server"
)
// msgItem adapts a received message to bubbles/list.Item.
type msgItem struct{ m server.ReceivedMessage }
func (i msgItem) Title() string { return i.m.From }
func (i msgItem) Description() string { return i.m.Text }
func (i msgItem) FilterValue() string { return i.m.From + " " + i.m.Text }
// msgItems builds list items from the model's messages (kept newest-first).
func (m Model) msgItems() []list.Item {
items := make([]list.Item, len(m.messages))
for i, msg := range m.messages {
items[i] = msgItem{m: msg}
}
return items
}
// Message list column widths.
const (
colFrom = 22
colTime = 7
)
// messageHeader is the dim column-header row above the message list.
func messageHeader() string {
h := lipgloss.NewStyle().Foreground(muted)
return " " +
h.Width(colFrom).Render("From") +
h.Width(colTime).Render("Time") +
h.Render("Message")
}
// msgDelegate renders one message as a row: ▌ from 15:04 preview…
type msgDelegate struct{}
func (msgDelegate) Height() int { return 1 }
func (msgDelegate) Spacing() int { return 0 }
func (msgDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (msgDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
it, ok := item.(msgItem)
if !ok {
return
}
from := truncate(nonEmpty(it.m.From, "unknown"), colFrom-2)
ts := it.m.Time.Format("15:04")
preview := truncate(oneLine(it.m.Text), 60)
fromSt := lipgloss.NewStyle().Width(colFrom)
timeSt := lipgloss.NewStyle().Width(colTime)
if index == m.Index() {
fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+
fromSt.Foreground(accent).Bold(true).Render(from)+
timeSt.Foreground(accent).Render(ts)+
lipgloss.NewStyle().Foreground(accent).Render(preview))
return
}
fmt.Fprint(w, " "+
fromSt.Foreground(text).Render(from)+
timeSt.Foreground(dim).Render(ts)+
lipgloss.NewStyle().Foreground(muted).Render(preview))
}
// composeView is the message-compose card.
func (m Model) composeView() string {
to := ""
if m.composeTo != nil {
to = m.composeTo.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message to " + to))
b.WriteString("\n\n")
b.WriteString(m.composeInput.View())
return b.String()
}
// readMessageView shows the full text of the message being read.
func (m Model) readMessageView() string {
if m.readingMsg == nil {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message from " + nonEmpty(m.readingMsg.From, "unknown")))
b.WriteString(" ")
b.WriteString(headerStyle.Render(m.readingMsg.Time.Format("2006-01-02 15:04")))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Width(56).Foreground(text).Render(m.readingMsg.Text))
return b.String()
}
func nonEmpty(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// oneLine collapses newlines so a multi-line message previews on one row.
func oneLine(s string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " "))
}