···11# Changelog
2233+# 2026-04-16
44+- **Redesigned welcome screen** — new two-column layout with ASCII art logo, philosophy/getting started guide on the left, and essential shortcuts organized by category on the right; wider box (100 chars) with cleaner spacing; maintains kanagawa color scheme; more scannable and visually appealing for new users
55+- **ASCII logo in help overlay** — pressing `?` now shows the neomd ASCII art logo overlaid on the top-right corner of the help screen; shortcuts start immediately at the top without vertical space taken by the logo; logo only appears when scrolled to the top
66+- **`space+w` welcome shortcut** — press `space` then `w` to reopen the welcome screen anytime; useful for reviewing keybindings and getting started guide; documented in help overlay and keybindings reference
77+- **`N` jump to next unread** — press `N` to jump to the next unread email in the current folder; wraps around to the beginning if no unread found after cursor; displays status message if no unread emails exist
88+- **`z` toggle unread-only view** — press `z` to filter the inbox to show only unread emails (mnemonic: "zero in on unread"); press `z` again to show all emails; works alongside text filter (`/`) and can be cleared with `esc`; status bar indicates current view mode
99+- **Fix: help menu search** — all keys (including `j`, `k`, `d`, `u`) are now available for typing when searching in help overlay (`?` then `/`); scroll keys only work when not in search mode
1010+311# 2026-04-15
412- **Scheduled folder keybindings** — added `gc` (go to Scheduled, mnemonic: "calendar") and `Mc` (move to Scheduled) shortcuts; Scheduled folder now accessible via dedicated keybindings alongside existing tab navigation (`[]HL`, `space+1-9`); help overlay and generated keybindings documentation updated
513
+20-1
docs/content/docs/configurations/proton-bridge.md
···13131414## neomd Configuration
15151616-Add the following to your `~/.config/neomd/config.toml`:
1616+Add the following to your `~/.config/neomd/config.toml` - notice that you cannot use folder named `Scheduled` as it's internally used for scheduled emails:
17171818```toml
1919[[accounts]]
···2525 from = "Your Name <your-proton-email@proton.me>"
2626 starttls = false # Proton Bridge uses TLS on non-standard ports
2727 tls_cert_file = "~/ProtonBridge/cert.pem" # optional: exported Bridge certificate
2828+2929+3030+[folders]
3131+ inbox = "INBOX"
3232+ sent = "Sent"
3333+ trash = "Trash"
3434+ drafts = "Drafts"
3535+ to_screen = "Folders/ToScreen"
3636+ feed = "Folders/Feed"
3737+ papertrail = "Folders/PaperTrail"
3838+ screened_out = "Folders/ScreenedOut"
3939+ archive = "Archive"
4040+ waiting = "Folders/Waiting"
4141+ scheduled = "Folders/sched"
4242+ someday = "Folders/Someday"
4343+ spam = "Spam"
4444+ work = ""
4545+ tab_order = ["inbox", "to_screen", "feed", "papertrail", "waiting", "sched", "someday", "archive", "sent", "drafts", "screened_out", "spam", "trash"]
4646+2847```
29483049## Key Configuration Details
+3
docs/content/docs/keybindings.md
···9595|-----|--------|
9696| `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) |
9797| `<space>/` | IMAP search ALL emails on server (From + Subject) |
9898+| `<space>w` | show welcome screen |
989999100100101### Sort (, prefix)
···116117| Key | Action |
117118|-----|--------|
118119| `n` | toggle read/unread (marked or cursor) |
120120+| `N` | jump to next unread email |
119121| `ctrl+n` | mark all in current folder as read |
120122| `R` | reload / refresh folder |
121123| `r` | reply (from inbox or reader) |
···179181| Key | Action |
180182|-----|--------|
181183| `/` | filter loaded emails (From + Subject, in-memory) |
184184+| `z` | toggle unread-only view (zoomed out/zero inbox) |
182185| `<space>/ or :search` | IMAP search ALL emails on server (From + Subject) |
183186| `?` | toggle this help |
184187| `q` | quit (from inbox) |
+3
internal/ui/keys.go
···7070 {"Leader Key Mappings (space prefix)", [][2]string{
7171 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"},
7272 {"<space>/", "IMAP search ALL emails on server (From + Subject)"},
7373+ {"<space>w", "show welcome screen"},
7374 }},
7475 {"Sort (, prefix)", [][2]string{
7576 {",m", "date newest first (default)"},
···8384 }},
8485 {"Email actions", [][2]string{
8586 {"n", "toggle read/unread (marked or cursor)"},
8787+ {"N", "jump to next unread email"},
8688 {"ctrl+n", "mark all in current folder as read"},
8789 {"R", "reload / refresh folder"},
8890 {"r", "reply (from inbox or reader)"},
···134136 }},
135137 {"General", [][2]string{
136138 {"/", "filter loaded emails (From + Subject, in-memory)"},
139139+ {"z", "toggle unread-only view (zoomed out/zero inbox)"},
137140 {"<space>/ or :search", "IMAP search ALL emails on server (From + Subject)"},
138141 {"?", "toggle this help"},
139142 {"q", "quit (from inbox)"},
+131-84
internal/ui/model.go
···511511 filterActive bool
512512 filterText string
513513514514+ // showUnreadOnly filters the inbox to show only unread emails when true.
515515+ // Toggled with 'v' key.
516516+ showUnreadOnly bool
517517+514518 // pendingResetUIDs holds ToScreen UIDs awaiting y/n confirmation before
515519 // being bulk-moved back to Inbox.
516520 pendingResetUIDs []uint32
···21682172 return m, tea.Quit
2169217321702174 case "esc":
21712171- if m.filterText != "" {
21752175+ if m.filterText != "" || m.showUnreadOnly {
21722176 m.filterActive = false
21732177 m.filterText = ""
21782178+ m.showUnreadOnly = false
21742179 return m, m.applyFilter()
21752180 }
21762181 if m.imapSearchResults {
···2199220422002205 case " ": // leader key — wait for digit or shortcut
22012206 m.pendingKey = " "
22022202- m.status = "leader: 1-9 folder tab / IMAP search (esc to cancel)"
22072207+ m.status = "leader: 1-9 folder tab / IMAP search w welcome (esc to cancel)"
22032208 return m, nil
2204220922052210 case "M":
···23542359 m.loading = true
23552360 return m, tea.Batch(m.spinner.Tick, m.batchToggleSeenCmd(targets))
2356236123622362+ case "N":
23632363+ // Jump to next unread email
23642364+ current := m.inbox.Index()
23652365+ items := m.inbox.Items()
23662366+ for i := current + 1; i < len(items); i++ {
23672367+ if item, ok := items[i].(emailItem); ok {
23682368+ if !item.email.Seen {
23692369+ m.inbox.Select(i)
23702370+ return m, nil
23712371+ }
23722372+ }
23732373+ }
23742374+ // Wrap around to beginning
23752375+ for i := 0; i <= current; i++ {
23762376+ if item, ok := items[i].(emailItem); ok {
23772377+ if !item.email.Seen {
23782378+ m.inbox.Select(i)
23792379+ return m, nil
23802380+ }
23812381+ }
23822382+ }
23832383+ m.status = "No unread emails found."
23842384+ return m, nil
23852385+23572386 // ── Navigation ──────────────────────────────────────────────────
23582387 case "tab", "L", "]":
23592388 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders)
···23962425 case "/":
23972426 m.filterActive = true
23982427 m.filterText = ""
24282428+ return m, m.applyFilter()
24292429+24302430+ case "z":
24312431+ // Toggle unread-only filter
24322432+ m.showUnreadOnly = !m.showUnreadOnly
24332433+ if m.showUnreadOnly {
24342434+ m.status = "Showing unread only · z to show all"
24352435+ } else {
24362436+ m.status = "Showing all emails"
24372437+ }
23992438 return m, m.applyFilter()
2400243924012440 case "ctrl+n": // mark all loaded emails in this folder as read
···25832622 return result
25842623}
2585262425862586-// applyFilter filters m.emails by filterText and refreshes the list.
25872587-// Call this whenever filterText changes.
26252625+// applyFilter filters m.emails by filterText and showUnreadOnly and refreshes the list.
26262626+// Call this whenever filterText or showUnreadOnly changes.
25882627func (m *Model) applyFilter() tea.Cmd {
25892589- if m.filterText == "" {
25902590- return setEmails(&m.inbox, m.emails, m.markedUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse)
25912591- }
25922592- query := strings.ToLower(m.filterText)
25932628 var filtered []imap.Email
26292629+26302630+ // Apply both text filter and unread filter
25942631 for _, e := range m.emails {
25952595- hay := strings.ToLower(e.From + " " + e.Subject)
25962596- if strings.Contains(hay, query) {
25972597- filtered = append(filtered, e)
26322632+ // Skip if unread-only mode is on and email is read
26332633+ if m.showUnreadOnly && e.Seen {
26342634+ continue
25982635 }
26362636+26372637+ // Skip if text filter is active and doesn't match
26382638+ if m.filterText != "" {
26392639+ query := strings.ToLower(m.filterText)
26402640+ hay := strings.ToLower(e.From + " " + e.Subject)
26412641+ if !strings.Contains(hay, query) {
26422642+ continue
26432643+ }
26442644+ }
26452645+26462646+ filtered = append(filtered, e)
25992647 }
26482648+26492649+ // If no filters are active, use all emails
26502650+ if m.filterText == "" && !m.showUnreadOnly {
26512651+ filtered = m.emails
26522652+ }
26532653+26002654 return setEmails(&m.inbox, filtered, m.markedUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse)
26012655}
26022656···26082662 m.imapSearchActive = true
26092663 m.imapSearchText = ""
26102664 m.imapSearchResults = false
26652665+ return m, nil
26662666+ }
26672667+ if key == "w" {
26682668+ m.state = stateWelcome
26112669 return m, nil
26122670 }
26132671 if len(key) == 1 && key >= "1" && key <= "9" {
···42414299 m.helpSearchActive = true
42424300 }
42434301 case "j", "down":
42444244- if !m.helpSearchActive {
43024302+ if m.helpSearchActive {
43034303+ m.helpSearch += "j"
43044304+ m.helpScroll = 0
43054305+ } else {
42454306 m.helpScroll++
42464307 }
42474308 case "k", "up":
42484248- if !m.helpSearchActive && m.helpScroll > 0 {
43094309+ if m.helpSearchActive {
43104310+ m.helpSearch += "k"
43114311+ m.helpScroll = 0
43124312+ } else if m.helpScroll > 0 {
42494313 m.helpScroll--
42504314 }
42514251- case "d", "ctrl+d":
43154315+ case "d":
43164316+ if m.helpSearchActive {
43174317+ m.helpSearch += "d"
43184318+ m.helpScroll = 0
43194319+ } else {
43204320+ m.helpScroll += m.helpPageSize()
43214321+ }
43224322+ case "ctrl+d":
42524323 if !m.helpSearchActive {
42534324 m.helpScroll += m.helpPageSize()
42544325 }
42554255- case "u", "ctrl+u":
43264326+ case "u":
43274327+ if m.helpSearchActive {
43284328+ m.helpSearch += "u"
43294329+ m.helpScroll = 0
43304330+ } else {
43314331+ m.helpScroll -= m.helpPageSize()
43324332+ }
43334333+ case "ctrl+u":
42564334 if !m.helpSearchActive {
42574335 m.helpScroll -= m.helpPageSize()
42584336 }
···42664344 return m, nil
42674345}
4268434642694269-func (m Model) viewWelcome() string {
42704270- boxWidth := 64
42714271- box := lipgloss.NewStyle().
42724272- Border(lipgloss.RoundedBorder()).
42734273- BorderForeground(colorPrimary).
42744274- Padding(1, 3).
42754275- Width(boxWidth)
43474347+func (m Model) viewHelp() string {
43484348+ keyStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Width(24)
43494349+ titleStyle := lipgloss.NewStyle().Foreground(colorDateCol).Bold(true)
43504350+ descStyle := lipgloss.NewStyle().Foreground(colorText)
43514351+ matchStyle := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true)
4276435242774277- title := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
42784278- key := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true)
42794279- dim := lipgloss.NewStyle().Foreground(colorDateCol)
43534353+ filter := strings.ToLower(m.helpSearch)
4280435442814281- warn := lipgloss.NewStyle().Foreground(colorError)
43554355+ // Build header with logo on the right (only if at top of scroll)
43564356+ var headerLines []string
43574357+ if m.helpScroll == 0 {
43584358+ heading := styleHeader.Render(" Keyboard shortcuts")
43594359+ logo := asciiLogoCompact(colorPrimary)
43604360+ logoLines := strings.Split(strings.TrimPrefix(logo, "\n"), "\n")
4282436142834283- content := title.Render("Welcome to neomd!") + "\n\n" +
42844284- "Your IMAP folders have been created automatically.\n\n" +
42854285- title.Render("Quick start") + "\n" +
42864286- key.Render(" j/k") + " navigate " + key.Render("enter") + " open email\n" +
42874287- key.Render(" c") + " compose " + key.Render("r") + " reply\n" +
42884288- key.Render(" f") + " forward " + key.Render("ctrl+r") + " reply-all\n" +
42894289- key.Render(" ]") + " / " + key.Render("[") + " next/prev tab " + key.Render("?") + " all keys\n\n" +
42904290- title.Render("How the Screener works") + "\n" +
42914291- "Your screener lists are empty, so " + warn.Render("auto-screening") + "\n" +
42924292- warn.Render("is paused") + " until you classify your first senders.\n\n" +
42934293- title.Render("Getting started") + "\n" +
42944294- "1. Go to " + key.Render("Inbox") + " tab; once screener is active, use " + key.Render("ToScreen") + " (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" +
42954295- "2. Screen each sender:\n" +
42964296- key.Render(" I") + " screen " + title.Render("in") + " " + dim.Render("sender stays in Inbox forever") + "\n" +
42974297- key.Render(" O") + " screen " + title.Render("out") + " " + dim.Render("sender never reaches Inbox again") + "\n" +
42984298- key.Render(" F") + " feed " + dim.Render("newsletters go to Feed tab") + "\n" +
42994299- key.Render(" P") + " papertrail " + dim.Render("receipts go to PaperTrail tab") + "\n" +
43004300- "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n" +
43014301- "4. Normal loads only screen the newest " + key.Render(strconv.Itoa(m.cfg.UI.InboxCount)) + " Inbox emails\n" +
43024302- "5. Use " + key.Render(":screen-all") + " for the full Inbox on the server " + dim.Render("(slower; mailbox-wide)") + "\n\n" +
43034303- dim.Render("Once classified, senders are remembered forever.") + "\n" +
43044304- dim.Render("New emails auto-sort on every load. You choose") + "\n" +
43054305- dim.Render("who lands in your inbox. Bye-bye spam.") + "\n\n" +
43064306- dim.Render("Inline <leader>a attachments in nvim require custom.lua + yazi.") + "\n" +
43074307- dim.Render("Disable auto-screen: auto_screen_on_load = false") + "\n" +
43084308- dim.Render("Diagnostics: :debug All keys: ?") + "\n\n" +
43094309- dim.Render("Press any key to continue.")
43624362+ // Shorten separator to fit left column only
43634363+ leftColWidth := 70
43644364+ sep := styleSeparator.Render(strings.Repeat("─", leftColWidth))
4310436543114311- rendered := box.Render(content)
43664366+ // Create header rows with logo on the right
43674367+ leftStyle := lipgloss.NewStyle().Width(leftColWidth).Align(lipgloss.Left)
4312436843134313- // Center vertically and horizontally
43144314- lines := strings.Count(rendered, "\n") + 1
43154315- padTop := (m.height - lines) / 2
43164316- if padTop < 0 {
43174317- padTop = 0
43184318- }
43194319- padLeft := (m.width - boxWidth) / 2
43204320- if padLeft < 0 {
43214321- padLeft = 0
43224322- }
43234323- prefix := strings.Repeat(" ", padLeft)
43244324- var b strings.Builder
43254325- for i := 0; i < padTop; i++ {
43264326- b.WriteByte('\n')
43274327- }
43284328- for _, line := range strings.Split(rendered, "\n") {
43294329- b.WriteString(prefix + line + "\n")
43304330- }
43314331- return b.String()
43324332-}
43694369+ // First line: heading + logo line 1
43704370+ headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top,
43714371+ leftStyle.Render(heading),
43724372+ logoLines[0]))
4333437343344334-func (m Model) viewHelp() string {
43354335- heading := styleHeader.Render(" Keyboard shortcuts")
43364336- sep := styleSeparator.Render(strings.Repeat("─", m.width))
43744374+ // Second line: separator + logo line 2
43754375+ headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top,
43764376+ leftStyle.Render(sep),
43774377+ logoLines[1]))
4337437843384338- keyStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Width(24)
43394339- titleStyle := lipgloss.NewStyle().Foreground(colorDateCol).Bold(true)
43404340- descStyle := lipgloss.NewStyle().Foreground(colorText)
43414341- matchStyle := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true)
43424342-43434343- filter := strings.ToLower(m.helpSearch)
43794379+ // Remaining logo lines with empty left side
43804380+ for i := 2; i < len(logoLines); i++ {
43814381+ headerLines = append(headerLines, lipgloss.JoinHorizontal(lipgloss.Top,
43824382+ leftStyle.Render(""),
43834383+ logoLines[i]))
43844384+ }
43854385+ } else {
43864386+ // When scrolled, just show normal header
43874387+ heading := styleHeader.Render(" Keyboard shortcuts")
43884388+ sep := styleSeparator.Render(strings.Repeat("─", m.width))
43894389+ headerLines = []string{heading, sep}
43904390+ }
4344439143454345- lines := []string{heading, sep}
43924392+ lines := headerLines
43464393 for _, sec := range HelpSections {
43474394 var matched [][2]string
43484395 for _, row := range sec.Rows {