Skip to content

Commit bcff1d5

Browse files
committed
fixed tui breaking on chat list
1 parent 400dbfd commit bcff1d5

5 files changed

Lines changed: 142 additions & 5 deletions

File tree

‎PLAN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Implementation is past the local-shell phase and currently sits at a DB-first, l
2929
- Chat title quality tracking with source precedence, group/contact metadata refresh, and safe placeholders so group JIDs/phone-like IDs are not treated as real names.
3030
- Direct-chat identity hardening for WhatsApp PN/LID aliases: canonicalize mapped 1:1 chats onto a single chat ID, merge already-split alias rows/messages/drafts/history cursors in SQLite, and preserve the active conversation when a live merge remaps the selected chat.
3131
- Large-history TUI guardrails: paired live startup immediately shows a blocking sync/check overlay only for local DB catch-up, historical imports avoid refresh storms, implicit database imports show a blocking local-update progress overlay even when WhatsApp omits an offline-sync preview, reconnect catch-up sync batches snapshot refreshes until completion, live refreshes are debounced, chat-list preview text is stored on chats instead of recomputed through per-chat message subqueries during every refresh, preview recomputation is skipped for older message/media mutations that cannot affect the latest chat preview, stale snapshot reloads do not steal chat focus, message rendering and cursor visibility use full message coordinates while clipping the final viewport, message cursor scrolling behaves like the chat list viewport, duplicate in-flight history requests are suppressed, send/draft/read-receipt/reaction/retry/sticker/lazy-load/history/filter/mention TUI callbacks return asynchronous result messages instead of blocking `Update`, message filtering uses the maintained FTS index instead of a chat-wide scan, UI-facing store callbacks use bounded contexts, remote media and sticker download work observes caller deadlines, clipboard image transfer has bounded memory/file-size limits, desktop notification bursts surface backpressure instead of silently dropping everything, and pixel media/avatar/sticker overlays keep low-resolution fallbacks visible while sync is pending, avoid chat-avatar overlay pauses on snapshot reloads when the visible avatar placement inputs are unchanged, reserve blank space only while paused for scroll/focus/resize transitions, then resync with bounded sync contexts and a one-shot retry after overlay sync failure.
32-
- Terminal/UI polish for real chat data: full/compat/auto emoji rendering, stable emoji fallback for terminals such as `st`, Windows Terminal, and classic Windows console hosts, Windows console output-mode setup with a two-column guarded render width plus a final frame clamp to avoid edge-wrap frame corruption, Windows terminal-size polling plus a lower default renderer FPS for smoother ConPTY behavior, pywal-backed mode indicators with per-mode hex overrides, focused-pane borders that distinguish chat-list vs message-pane input focus, highlighted unread-count badges in the chat list, a structured keymap-driven help screen, non-redundant mode prompts, configurable help/prompt key hints, search match counts in the status bar, configurable cancellation/search-clear bindings, platform-specific terminal focus-reporting options, and native Windows runtime defaults that do not affect Linux behavior.
32+
- Terminal/UI polish for real chat data: full/compat/auto emoji rendering, stable emoji fallback for terminals such as `st`, Windows Terminal, and classic Windows console hosts, Windows console output-mode setup with a two-column guarded render width plus a final frame clamp to avoid edge-wrap frame corruption, Unix terminfo detection that enables the same last-column guard for `am+xenl` terminals such as `st`, Windows terminal-size polling plus a lower default renderer FPS for smoother ConPTY behavior, pywal-backed mode indicators with per-mode hex overrides, focused-pane borders that distinguish chat-list vs message-pane input focus, highlighted unread-count badges in the chat list, a structured keymap-driven help screen, non-redundant mode prompts, configurable help/prompt key hints, search match counts in the status bar, configurable cancellation/search-clear bindings, platform-specific terminal focus-reporting options, and native Windows runtime defaults that do not affect Linux behavior.
3333
- Search now folds accents only for accent-free queries, so `/Jose` matches `José` while `/José` stays accent-sensitive, with matching highlight behavior in the TUI and the same rule in store-backed chat/message filtering.
3434
- Demo/dev workflows that exercise the full local UI without a live WhatsApp session.
3535

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/mdp/qrterminal/v3 v3.2.1
1010
github.com/muesli/termenv v0.16.0
1111
github.com/rivo/uniseg v0.4.7
12+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
1213
go.mau.fi/whatsmeow v0.0.0-20260123225751-89be06b020db
1314
modernc.org/sqlite v1.48.2
1415
)
@@ -37,7 +38,6 @@ require (
3738
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3839
github.com/rs/zerolog v1.34.0 // indirect
3940
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
40-
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
4141
go.mau.fi/libsignal v0.2.1 // indirect
4242
go.mau.fi/util v0.9.5 // indirect
4343
golang.org/x/crypto v0.47.0 // indirect

‎internal/ui/model_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3399,6 +3399,46 @@ func TestChatTopAndBottomCommandsKeepActiveCellVisible(t *testing.T) {
33993399
}
34003400
}
34013401

3402+
func TestChatTopNavigationWithLastColumnGuardDoesNotTouchTerminalEdge(t *testing.T) {
3403+
chats := numberedChats(8)
3404+
chats[0].Unread = 20
3405+
chats[0].AvatarPath = "/tmp/avatar-top.jfif"
3406+
messages := map[string][]store.Message{}
3407+
for _, chat := range chats {
3408+
messages[chat.ID] = nil
3409+
}
3410+
model := NewModel(Options{
3411+
ReserveLastColumn: true,
3412+
Snapshot: store.Snapshot{
3413+
Chats: chats,
3414+
MessagesByChat: messages,
3415+
DraftsByChat: map[string]string{},
3416+
ActiveChatID: "chat-7",
3417+
},
3418+
})
3419+
model.width = 80
3420+
model.height = 24
3421+
model.focus = FocusChats
3422+
3423+
top, _ := model.updateNormal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")})
3424+
model = top.(Model)
3425+
if model.activeChat != 0 || model.chatScrollTop != 0 {
3426+
t.Fatalf("g activeChat=%d chatScrollTop=%d, want top visible", model.activeChat, model.chatScrollTop)
3427+
}
3428+
3429+
view := stripANSI(model.View())
3430+
lines := strings.Split(view, "\n")
3431+
if len(lines) > model.height {
3432+
t.Fatalf("View() produced %d lines, want <= %d\n%s", len(lines), model.height, view)
3433+
}
3434+
maxWidth := model.width - lastColumnGuardMargin
3435+
for i, line := range lines {
3436+
if width := lipgloss.Width(line); width > maxWidth {
3437+
t.Fatalf("line %d width = %d, want <= %d with last-column guard\n%s", i+1, width, maxWidth, view)
3438+
}
3439+
}
3440+
}
3441+
34023442
func TestChatSearchKeepsMatchedCellVisible(t *testing.T) {
34033443
chats := numberedChats(8)
34043444
chats[6].Title = "Needle Team"

‎internal/ui/terminal_output_unix.go‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,48 @@
22

33
package ui
44

5-
import "runtime"
5+
import (
6+
"fmt"
7+
"os"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/xo/terminfo"
12+
)
613

714
func prepareTerminalOutput() (TerminalReport, func() error) {
8-
return TerminalReport{
15+
report := TerminalReport{
916
Platform: runtime.GOOS,
1017
VTProcessing: true,
1118
DelayedNewline: true,
12-
}, func() error { return nil }
19+
}
20+
applyUnixLastColumnGuard(&report, os.Getenv("TERM"), terminfo.LoadFromEnv)
21+
return report, func() error { return nil }
22+
}
23+
24+
func applyUnixLastColumnGuard(report *TerminalReport, term string, load func() (*terminfo.Terminfo, error)) {
25+
if report == nil || load == nil {
26+
return
27+
}
28+
info, err := load()
29+
if err != nil {
30+
return
31+
}
32+
if !unixNeedsLastColumnGuard(info) {
33+
return
34+
}
35+
report.LastColumnGuard = true
36+
detail := "last-column guard enabled by terminfo am+xenl"
37+
if term = strings.TrimSpace(term); term != "" {
38+
detail = fmt.Sprintf("last-column guard enabled for TERM=%s by terminfo am+xenl", term)
39+
}
40+
report.Detail = joinTerminalDetails(report.Detail, detail)
41+
}
42+
43+
func unixNeedsLastColumnGuard(info *terminfo.Terminfo) bool {
44+
return unixBoolCap(info, terminfo.AutoRightMargin) && unixBoolCap(info, terminfo.EatNewlineGlitch)
45+
}
46+
47+
func unixBoolCap(info *terminfo.Terminfo, cap int) bool {
48+
return info != nil && info.Bools != nil && info.Bools[cap]
1349
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build !windows
2+
3+
package ui
4+
5+
import (
6+
"errors"
7+
"strings"
8+
"testing"
9+
10+
"github.com/xo/terminfo"
11+
)
12+
13+
func TestUnixLastColumnGuardUsesTerminfoXenl(t *testing.T) {
14+
report := TerminalReport{Platform: "linux", VTProcessing: true, DelayedNewline: true}
15+
16+
applyUnixLastColumnGuard(&report, "st-256color", func() (*terminfo.Terminfo, error) {
17+
return &terminfo.Terminfo{Bools: map[int]bool{
18+
terminfo.AutoRightMargin: true,
19+
terminfo.EatNewlineGlitch: true,
20+
}}, nil
21+
})
22+
23+
if !report.LastColumnGuard {
24+
t.Fatal("LastColumnGuard = false, want enabled for am+xenl terminal")
25+
}
26+
if !strings.Contains(report.Detail, "TERM=st-256color") || !strings.Contains(report.Detail, "am+xenl") {
27+
t.Fatalf("Detail = %q, want terminfo guard context", report.Detail)
28+
}
29+
}
30+
31+
func TestUnixLastColumnGuardRequiresXenl(t *testing.T) {
32+
report := TerminalReport{Platform: "linux", VTProcessing: true, DelayedNewline: true}
33+
34+
applyUnixLastColumnGuard(&report, "xterm-256color", func() (*terminfo.Terminfo, error) {
35+
return &terminfo.Terminfo{Bools: map[int]bool{
36+
terminfo.AutoRightMargin: true,
37+
}}, nil
38+
})
39+
40+
if report.LastColumnGuard {
41+
t.Fatal("LastColumnGuard = true, want disabled without xenl")
42+
}
43+
if report.Detail != "" {
44+
t.Fatalf("Detail = %q, want empty without guard", report.Detail)
45+
}
46+
}
47+
48+
func TestUnixLastColumnGuardIgnoresTerminfoLoadFailure(t *testing.T) {
49+
report := TerminalReport{Platform: "linux", VTProcessing: true, DelayedNewline: true}
50+
51+
applyUnixLastColumnGuard(&report, "unknown", func() (*terminfo.Terminfo, error) {
52+
return nil, errors.New("missing terminfo")
53+
})
54+
55+
if report.LastColumnGuard {
56+
t.Fatal("LastColumnGuard = true, want disabled when terminfo cannot be loaded")
57+
}
58+
if report.Detail != "" {
59+
t.Fatalf("Detail = %q, want empty on load failure", report.Detail)
60+
}
61+
}

0 commit comments

Comments
 (0)