Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Content =
| { type: "attachment"; name: string; mimeType: string; size?: number; bytes?: string /* base64 */; path?: string }
| { type: "voice"; name?: string; mimeType: string; size?: number; bytes?: string; path?: string }
| { type: "contact"; name?: { formatted?: string; first?: string; last?: string; middle?: string; prefix?: string; suffix?: string }; vcard?: string }
| { type: "richlink"; url: string; title?: string; summary?: string; cover?: { mimeType?: string; bytes?: string /* base64 */ } }
| { type: "custom"; raw: unknown };
```

Expand Down
9 changes: 9 additions & 0 deletions internal/plain/plain.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ func plainFormat(c protocol.Content) string {
return "[custom] " + string(raw)
}
return "[custom]"
case "richlink":
label := c.Title
if label == "" {
label = c.Url
}
if c.Summary != "" {
return fmt.Sprintf("[link] %s — %s", label, c.Summary)
}
return fmt.Sprintf("[link] %s", label)
}
return ""
}
Expand Down
48 changes: 48 additions & 0 deletions internal/plain/plain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package plain

import (
"testing"

"github.com/photon-hq/tuichat/internal/protocol"
)

func TestPlainFormatRichlink(t *testing.T) {
tests := []struct {
name string
content protocol.Content
want string
}{
{
name: "url only",
content: protocol.Content{Type: "richlink", Url: "https://example.com/x"},
want: "[link] https://example.com/x",
},
{
name: "title without summary",
content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Title: "Hello"},
want: "[link] Hello",
},
{
name: "title with summary",
content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Title: "Hello", Summary: "world"},
want: "[link] Hello — world",
},
{
name: "summary without title falls back to url label",
content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Summary: "world"},
want: "[link] https://example.com/x — world",
},
{
name: "missing url and title renders bracket only",
content: protocol.Content{Type: "richlink"},
want: "[link] ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := plainFormat(tt.content); got != tt.want {
t.Errorf("plainFormat() = %q, want %q", got, tt.want)
}
})
}
}
9 changes: 9 additions & 0 deletions internal/protocol/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ type Content struct {
// Contact fields deliberately passed through via Raw on the client side
// when needed. We don't model contact/voice extensively in Go today —
// they fall through as custom-like payloads.
Url string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Summary string `json:"summary,omitempty"`
Cover *Cover `json:"cover,omitempty"`
}

type Cover struct {
MimeType string `json:"mimeType,omitempty"`
Bytes string `json:"bytes,omitempty"` // base64-encoded
}

type CommandDef struct {
Expand Down
31 changes: 31 additions & 0 deletions internal/protocol/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package protocol

import (
"encoding/json"
"testing"
)

func TestContentUnmarshalRichlinkCover(t *testing.T) {
var content Content
err := json.Unmarshal([]byte(`{
"type": "richlink",
"url": "https://example.com/article",
"title": "Example",
"cover": {
"mimeType": "image/png",
"bytes": "aGVsbG8="
}
}`), &content)
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if content.Cover == nil {
t.Fatal("Cover = nil, want value")
}
if content.Cover.MimeType != "image/png" {
t.Fatalf("Cover.MimeType = %q, want %q", content.Cover.MimeType, "image/png")
}
if content.Cover.Bytes != "aGVsbG8=" {
t.Fatalf("Cover.Bytes = %q, want %q", content.Cover.Bytes, "aGVsbG8=")
}
}
65 changes: 65 additions & 0 deletions internal/ui/entry_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package ui

import (
"encoding/base64"

"github.com/photon-hq/tuichat/internal/kitty"
"github.com/photon-hq/tuichat/internal/protocol"
"github.com/photon-hq/tuichat/internal/store"
)

func richlinkLabel(c protocol.Content) string {
if c.Title != "" {
return c.Title
}
if c.Url != "" {
return c.Url
}
return ""
}

func entrySupportsPreview(e store.LogEntry) bool {
switch e.Content.Type {
case "attachment":
return kitty.SupportedMimeType(e.Content.MimeType)
case "richlink":
return e.Content.Cover != nil &&
e.Content.Cover.Bytes != "" &&
kitty.SupportedMimeType(e.Content.Cover.MimeType)
default:
return false
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func previewForEntry(e store.LogEntry) (*store.HoveredPreview, bool) {
switch e.Content.Type {
case "attachment":
if !entrySupportsPreview(e) {
return nil, false
}
return &store.HoveredPreview{
CacheKey: e.Content.Name,
Name: e.Content.Name,
Path: e.AttachmentPath,
}, true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "richlink":
if !entrySupportsPreview(e) {
return nil, false
}
raw, err := base64.StdEncoding.DecodeString(e.Content.Cover.Bytes)
if err != nil || len(raw) == 0 {
return nil, false
}
name := richlinkLabel(e.Content)
if name == "" {
name = "link preview"
}
return &store.HoveredPreview{
CacheKey: e.ID + ":richlink-cover",
Name: name,
Bytes: raw,
}, true
default:
return nil, false
}
}
85 changes: 85 additions & 0 deletions internal/ui/entry_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package ui

import (
"bytes"
"encoding/base64"
"testing"

"github.com/photon-hq/tuichat/internal/protocol"
"github.com/photon-hq/tuichat/internal/store"
)

func TestPreviewForEntryRichlinkCover(t *testing.T) {
coverBytes := []byte{0x89, 0x50, 0x4e, 0x47}
entry := store.LogEntry{
ID: "msg-1",
Content: protocol.Content{
Type: "richlink",
Url: "https://example.com/article",
Title: "Example article",
Cover: &protocol.Cover{
MimeType: "image/png",
Bytes: base64.StdEncoding.EncodeToString(coverBytes),
},
},
}

preview, ok := previewForEntry(entry)
if !ok {
t.Fatal("previewForEntry() ok = false, want true")
}
if preview.CacheKey != "msg-1:richlink-cover" {
t.Fatalf("CacheKey = %q, want %q", preview.CacheKey, "msg-1:richlink-cover")
}
if preview.Name != "Example article" {
t.Fatalf("Name = %q, want %q", preview.Name, "Example article")
}
if !bytes.Equal(preview.Bytes, coverBytes) {
t.Fatalf("Bytes = %v, want %v", preview.Bytes, coverBytes)
}
}

func TestPreviewForEntryRichlinkCoverRejectsInvalidPreview(t *testing.T) {
tests := []struct {
name string
content protocol.Content
}{
{
name: "missing cover",
content: protocol.Content{
Type: "richlink",
Url: "https://example.com/article",
},
},
{
name: "unsupported mime",
content: protocol.Content{
Type: "richlink",
Url: "https://example.com/article",
Cover: &protocol.Cover{
MimeType: "text/plain",
Bytes: base64.StdEncoding.EncodeToString([]byte("hello")),
},
},
},
{
name: "invalid base64",
content: protocol.Content{
Type: "richlink",
Url: "https://example.com/article",
Cover: &protocol.Cover{
MimeType: "image/png",
Bytes: "not-base64",
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, ok := previewForEntry(store.LogEntry{ID: "msg-1", Content: tt.content}); ok {
t.Fatal("previewForEntry() ok = true, want false")
}
})
}
}
19 changes: 17 additions & 2 deletions internal/ui/messagelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/photon-hq/tuichat/internal/kitty"
"github.com/photon-hq/tuichat/internal/store"
)

Expand Down Expand Up @@ -53,7 +52,7 @@ func renderEntry(theme Theme, e store.LogEntry, allEntries []store.LogEntry, wid
body = LinkifyText(e.Content.Text, textStyle, linkStyle)
case "attachment":
body = renderAttachmentLabel(theme, e, "attachment")
if kitty.SupportedMimeType(e.Content.MimeType) {
if entrySupportsPreview(e) {
hint := lipgloss.NewStyle().Foreground(theme.SystemColor).Render(
" (click to preview)",
)
Expand All @@ -67,6 +66,20 @@ func renderEntry(theme Theme, e store.LogEntry, allEntries []store.LogEntry, wid
attachStyle := lipgloss.NewStyle().Foreground(theme.CustomColor)
body = attachStyle.Render("[custom] ") +
lipgloss.NewStyle().Foreground(theme.SystemColor).Render(safeStringify(e.Content.Raw))
case "richlink":
linkStyle := lipgloss.NewStyle().Foreground(theme.PromptColor).Underline(true)
label := richlinkLabel(e.Content)
body = lipgloss.NewStyle().Foreground(theme.AttachmentColor).Render("[link] ") + linkStyle.Render(label)
if entrySupportsPreview(e) {
hint := lipgloss.NewStyle().Foreground(theme.SystemColor).Render(
" (click to preview)",
)
body += hint
}
if e.Content.Summary != "" {
summaryStyle := lipgloss.NewStyle().Foreground(theme.SystemColor)
body += "\n " + summaryStyle.Render(e.Content.Summary)
}
default:
return ""
}
Expand Down Expand Up @@ -204,6 +217,8 @@ func quoteBody(e store.LogEntry) string {
return "[contact]"
case "custom":
return "[custom]"
case "richlink":
return truncateRunes("[link] "+richlinkLabel(c), 60)
}
return ""
}
Expand Down
14 changes: 5 additions & 9 deletions internal/ui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"

"github.com/photon-hq/tuichat/internal/kitty"
"github.com/photon-hq/tuichat/internal/store"
)

Expand Down Expand Up @@ -186,25 +185,22 @@ func (m *Model) dispatchClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}

// Attachment chip clicks → toggle preview.
// Image preview chip clicks → toggle the floating preview panel.
if active, ok := m.Store.ActiveChat(); ok {
for i := range active.Entries {
e := active.Entries[i]
if e.Content.Type != "attachment" || !kitty.SupportedMimeType(e.Content.MimeType) {
preview, ok := previewForEntry(e)
if !ok {
continue
}
zoneID := ZoneAttachmentPrefix + e.ID
if !zone.Get(zoneID).InBounds(msg) {
continue
}
if hovered := m.Store.HoveredPreview(); hovered != nil && hovered.CacheKey == e.Content.Name {
if hovered := m.Store.HoveredPreview(); hovered != nil && hovered.CacheKey == preview.CacheKey {
m.Store.SetHoveredPreview(nil)
} else {
m.Store.SetHoveredPreview(&store.HoveredPreview{
CacheKey: e.Content.Name,
Name: e.Content.Name,
Path: e.AttachmentPath,
})
m.Store.SetHoveredPreview(preview)
}
return m, nil
}
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ func (m *Model) renderReplyBanner(chat store.ChatState, width int) string {
quote = "[contact]"
case "custom":
quote = "[custom]"
case "richlink":
label := e.Content.Title
if label == "" {
label = e.Content.Url
}
quote = "[link] " + label
}
break
}
Expand Down
3 changes: 1 addition & 2 deletions internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
zone "github.com/lrstanley/bubblezone"
"github.com/mattn/go-runewidth"

"github.com/photon-hq/tuichat/internal/kitty"
"github.com/photon-hq/tuichat/internal/store"
)

Expand Down Expand Up @@ -311,7 +310,7 @@ func (m *Model) zoneMarkEntries(theme Theme, chat store.ChatState, width int) st
rendered = strings.ReplaceAll(rendered, "\x1b[0m", "\x1b[0m"+bgOpen)
rendered = selectedBG.Render(rendered)
}
if e.Content.Type == "attachment" && kitty.SupportedMimeType(e.Content.MimeType) {
if entrySupportsPreview(e) {
rendered = zone.Mark(ZoneAttachmentPrefix+e.ID, rendered)
}
// Every entry gets a click-zone so mouse-select works.
Expand Down