Skip to content
Open
Show file tree
Hide file tree
Changes from all 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=")
}
}
77 changes: 77 additions & 0 deletions internal/ui/entry_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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":
if e.Content.Cover == nil || !kitty.SupportedMimeType(e.Content.Cover.MimeType) {
return false
}
raw, err := base64.StdEncoding.DecodeString(e.Content.Cover.Bytes)
return err == nil && len(raw) > 0
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: attachmentPreviewCacheKey(e),
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
}
}

func attachmentPreviewCacheKey(e store.LogEntry) string {
if e.AttachmentPath != "" {
return e.AttachmentPath + ":" + e.Content.Name
}
if e.ID != "" {
return e.ID + ":attachment"
}
return e.Content.Name
}
114 changes: 114 additions & 0 deletions internal/ui/entry_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 TestPreviewForEntryAttachmentCacheKeyUsesPath(t *testing.T) {
entry := store.LogEntry{
ID: "msg-1",
AttachmentPath: "/tmp/tuichat-a/image.png",
Content: protocol.Content{
Type: "attachment",
Name: "image.png",
MimeType: "image/png",
},
}

preview, ok := previewForEntry(entry)
if !ok {
t.Fatal("previewForEntry() ok = false, want true")
}
if preview.CacheKey != "/tmp/tuichat-a/image.png:image.png" {
t.Fatalf("CacheKey = %q, want %q", preview.CacheKey, "/tmp/tuichat-a/image.png:image.png")
}
if preview.Name != "image.png" {
t.Fatalf("Name = %q, want %q", preview.Name, "image.png")
}
if preview.Path != "/tmp/tuichat-a/image.png" {
t.Fatalf("Path = %q, want %q", preview.Path, "/tmp/tuichat-a/image.png")
}
}

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 entrySupportsPreview(store.LogEntry{ID: "msg-1", Content: tt.content}) {
t.Fatal("entrySupportsPreview() = true, want false")
}
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
Loading