Skip to content
Merged
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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ Remember to update the examples in the README when you change, add or remove CLI

Some HEY API endpoints return 204 or incomplete data via JSON, but the full HTML content is available by scraping the edit page (e.g., `/calendar/days/{date}/journal_entry/edit` contains the Trix editor hidden input with full HTML). When an API endpoint returns incomplete data, check the corresponding web page for the full content. The `internal/htmlutil` package provides `ToText` (HTML→plain text), `ExtractImageURLs`, and `ParseTopicEntriesHTML` shared by both CLI and TUI. HEY uses Trix editor with `<figure data-trix-attachment="{...}">` for attachments — image URLs in those attributes are relative paths requiring authentication via `sdk.Get`.

### TUI structure

The TUI uses the `sectionView` interface pattern. Each top-level section (Mail, Calendar, Journal) implements `sectionView` and owns its data, fetch commands, key handling, rendering, and help bindings. The main model delegates to the active view.

| File | What it contains |
|------|-----------------|
| `section_view.go` | `sectionView` interface + `viewContext` shared dependencies |
| `tui.go` | Core model, `Update` router, `View`, key routing, shared utilities |
| `mail.go` | `mailView` — boxes, postings, topic threads, posting actions, entry rendering |
| `calendar.go` | `calendarView` — calendars, recordings, recording detail |
| `journal.go` | `journalView` — date navigation, journal entries |
| `nav.go` | Header rendering, section/box/calendar/journal nav items, shortcuts |
| `content.go` | `contentList` (posting list) and `recordingList` (recording list) |
| `loading.go` | Hourglass loading animation |
| `styles.go` | Colors, lipgloss styles, error display |
| `help.go` | Help bar at screen bottom |
| `kitty.go` | Kitty graphics protocol for inline images |
| `html.go` | Thin wrappers around `htmlutil` |

To add a new section: implement the `sectionView` interface in a new file, add a field and constructor call in `newModel`, and add a case in `switchSection`.

### Inline images in the TUI

The TUI renders inline images using the Kitty graphics protocol's Unicode Placeholder extension (`internal/tui/kitty.go`). This works because Bubble Tea's cell-based renderer corrupts raw APC escape sequences, but Unicode placeholders are regular text that survives rendering. The approach has three steps:
Expand Down
108 changes: 104 additions & 4 deletions internal/tui/nav.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,25 +163,125 @@ func renderRule(width int, label string) string {

// renderNavRow draws a row of nav items with the selected one bolded.
// If centered is true, the row is horizontally centered within width.
// When items overflow the available width, the row scrolls horizontally
// to keep the selected item visible and shows ‹/› indicators.
func renderNavRow(items []navItem, selected int, focused bool, width int, centered bool) string {
var parts []string
const sep = " "
sepW := lipgloss.Width(sep)

// Pre-render each item and measure its display width.
type rendered struct {
str string
w int
}
all := make([]rendered, len(items))
totalW := 0
for i, item := range items {
text := item.label
if item.icon != "" {
text = item.icon + " " + text
}

var s string
if i == selected {
style := lipgloss.NewStyle().Bold(true)
if focused {
style = style.Foreground(colorPrimary)
}
parts = append(parts, style.Render(text))
s = style.Render(text)
} else {
parts = append(parts, lipgloss.NewStyle().Foreground(colorMuted).Render(text))
s = lipgloss.NewStyle().Foreground(colorMuted).Render(text)
}
w := lipgloss.Width(s)
all[i] = rendered{s, w}
totalW += w
}
totalW += sepW * max(len(items)-1, 0) // separators

// If everything fits, no scrolling needed.
if totalW <= width {
parts := make([]string, len(all))
for i, r := range all {
parts[i] = r.str
}
row := strings.Join(parts, sep)
if centered {
rowWidth := lipgloss.Width(row)
pad := max((width-rowWidth)/2, 0)
return strings.Repeat(" ", pad) + row
}
return row
}
row := strings.Join(parts, " ")

// Scrolling: find the largest window of items around `selected` that fits.
leftArrow := lipgloss.NewStyle().Foreground(colorMuted).Render("‹ ")
rightArrow := lipgloss.NewStyle().Foreground(colorMuted).Render(" ›")
arrowW := lipgloss.Width(leftArrow) // both arrows have the same width

// Start with the selected item and expand outward.
lo, hi := selected, selected
usedW := all[selected].w

Comment on lines +216 to +224
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the scrolling path, usedW starts as just the selected item width, but the rendered row may also include left/right arrow indicators. If the selected item alone fits width but selectedItemWidth + arrow(s) does not, the expansion loop stops immediately yet the final row can still exceed width (causing wrapping/misalignment). Consider reserving arrow widths in the initial usedW (based on whether selected is at either edge), or conditionally omitting/truncating arrows/items when there isn’t enough room so the returned row is guaranteed to fit width.

Copilot uses AI. Check for mistakes.
for {
expandedLeft, expandedRight := false, false

// Try expanding left.
if lo > 0 {
need := sepW + all[lo-1].w
reserveR := 0
if hi < len(items)-1 {
reserveR = arrowW
}
reserveL := 0
if lo-1 > 0 {
reserveL = arrowW
}
if usedW+need+reserveL+reserveR <= width {
lo--
usedW += need
expandedLeft = true
}
}

// Try expanding right.
if hi < len(items)-1 {
need := sepW + all[hi+1].w
reserveL := 0
if lo > 0 {
reserveL = arrowW
}
reserveR := 0
if hi+1 < len(items)-1 {
reserveR = arrowW
}
if usedW+need+reserveL+reserveR <= width {
hi++
usedW += need
expandedRight = true
}
}

if !expandedLeft && !expandedRight {
break
}
}

// Build the visible row.
var b strings.Builder
if lo > 0 {
b.WriteString(leftArrow)
}
for i := lo; i <= hi; i++ {
if i > lo {
b.WriteString(sep)
}
b.WriteString(all[i].str)
}
if hi < len(items)-1 {
b.WriteString(rightArrow)
}

row := b.String()
if centered {
rowWidth := lipgloss.Width(row)
pad := max((width-rowWidth)/2, 0)
Expand Down
Loading