diff --git a/go.mod b/go.mod
index 01eda3d..0c0a76a 100644
--- a/go.mod
+++ b/go.mod
@@ -17,20 +17,26 @@ require (
)
require (
+ github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/openai/openai-go/v3 v3.50.0 // indirect
@@ -44,9 +50,13 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ github.com/yuin/goldmark v1.7.8 // indirect
+ github.com/yuin/goldmark-emoji v1.0.5 // indirect
+ golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index e734d3c..23ea1cf 100644
--- a/go.sum
+++ b/go.sum
@@ -8,8 +8,16 @@ charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/RandomCodeSpace/rig v0.2.0 h1:VJNRC2fB3KuVYmVyQ86Au8qF1Sv5PGpUazkoJ1H5Nh4=
github.com/RandomCodeSpace/rig v0.2.0/go.mod h1:+YcCXgc5DuA4COoaFiMBU5kNGEIk1tR7jNo9BDFUg7U=
+github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
+github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
+github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
@@ -18,6 +26,8 @@ github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504 h1:fDsPREsmS3KGgpqhk9e2s1Q1fTdW6n/9E9+jGpmuf7k=
github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -30,6 +40,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -42,14 +54,20 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -80,16 +98,25 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
diff --git a/internal/tui/carddetail/markdown.go b/internal/tui/carddetail/markdown.go
new file mode 100644
index 0000000..6a237d9
--- /dev/null
+++ b/internal/tui/carddetail/markdown.go
@@ -0,0 +1,399 @@
+package carddetail
+
+import (
+ "regexp"
+ "slices"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+type inlineKind uint8
+
+const (
+ inlineCode inlineKind = iota
+ inlineBold
+ inlineItalic
+ inlineLink
+ inlineAutoLink
+)
+
+type inlineMatch struct {
+ start int
+ end int
+ priority int
+ kind inlineKind
+ text string
+ href string
+}
+
+var (
+ codePattern = regexp.MustCompile("\\x60([^\\x60\\r\\n]+)\\x60")
+ boldPattern = regexp.MustCompile("\\*\\*([^*\\r\\n]+)\\*\\*")
+)
+
+// parityMarkdown reduces a description to the frozen web renderer's grammar
+// before Glamour sees it. Glamour deliberately understands much more
+// Markdown; escaping everything outside this allowlist keeps that extra syntax
+// literal instead of quietly widening the product contract.
+func parityMarkdown(source string) string {
+ lines := strings.Split(source, "\n")
+ out := make([]string, 0, len(lines)*2)
+ for i := 0; i < len(lines); i++ {
+ raw := lines[i]
+ if strings.HasPrefix(raw, strings.Repeat(string(rune(0x60)), 3)) {
+ var code []string
+ for i++; i < len(lines) && !strings.HasPrefix(lines[i], strings.Repeat(string(rune(0x60)), 3)); i++ {
+ code = append(code, lines[i])
+ }
+ out = append(out, safeFence(code), strings.Join(code, "\n"), safeFence(code), "")
+ continue
+ }
+
+ line, block := parityLine(raw)
+ out = append(out, line)
+ if !block {
+ out = append(out, "")
+ }
+ }
+ return strings.TrimRight(strings.Join(out, "\n"), "\n")
+}
+
+func parityLine(raw string) (line string, listItem bool) {
+ if _, text, ok := heading(raw); ok {
+ return "**" + inlineMarkdown(text, true) + "**", false
+ }
+ if strings.HasPrefix(raw, "- ") {
+ return "- " + inlineMarkdown(raw[2:], false), true
+ }
+ if markerEnd, textStart := orderedPrefix(raw); markerEnd > 0 {
+ // Keep the source ordinal literal. Glamour renumbers adjacent ordered
+ // list items, while the frozen web renderer preserves every marker.
+ return escapeMarkdown(raw[:markerEnd]) + " " + inlineMarkdown(raw[textStart:], false), false
+ }
+ return inlineMarkdown(raw, false), false
+}
+
+func heading(raw string) (int, string, bool) {
+ level := 0
+ for level < len(raw) && level < 4 && raw[level] == '#' {
+ level++
+ }
+ if level == 0 || level > 3 || level >= len(raw) || !jsWhitespace(firstRune(raw[level:])) {
+ return 0, "", false
+ }
+ textStart := level
+ for textStart < len(raw) {
+ r, size := utf8.DecodeRuneInString(raw[textStart:])
+ if !jsWhitespace(r) {
+ break
+ }
+ textStart += size
+ }
+ if strings.ContainsAny(raw[textStart:], "\r\n\u2028\u2029") {
+ return 0, "", false
+ }
+ return level, raw[textStart:], true
+}
+
+func orderedPrefix(raw string) (markerEnd, textStart int) {
+ digits := 0
+ for digits < len(raw) && digits < 3 && raw[digits] >= '0' && raw[digits] <= '9' {
+ digits++
+ }
+ if digits == 0 || digits+1 >= len(raw) || raw[digits] != '.' || !jsWhitespace(firstRune(raw[digits+1:])) {
+ return 0, 0
+ }
+ textStart = digits + 1
+ for textStart < len(raw) {
+ r, size := utf8.DecodeRuneInString(raw[textStart:])
+ if !jsWhitespace(r) {
+ break
+ }
+ textStart += size
+ }
+ if strings.ContainsAny(raw[textStart:], "\r\n\u2028\u2029") {
+ return 0, 0
+ }
+ return digits + 1, textStart
+}
+
+func safeFence(lines []string) string {
+ longest := 2
+ for _, line := range lines {
+ for _, run := range strings.FieldsFunc(line, func(r rune) bool { return r != '~' }) {
+ longest = max(longest, utf8.RuneCountInString(run))
+ }
+ }
+ return strings.Repeat("~", longest+1)
+}
+
+func inlineMarkdown(line string, insideHeading bool) string {
+ matches := inlineMatches(line)
+ var out strings.Builder
+ last := 0
+ for _, match := range matches {
+ if match.start < last {
+ continue
+ }
+ out.WriteString(escapeMarkdown(line[last:match.start]))
+ switch match.kind {
+ case inlineCode:
+ out.WriteRune(0x60)
+ out.WriteString(match.text)
+ out.WriteRune(0x60)
+ case inlineBold:
+ if insideHeading {
+ out.WriteString(escapeMarkdown(match.text))
+ } else {
+ out.WriteString("**")
+ out.WriteString(escapeMarkdown(match.text))
+ out.WriteString("**")
+ }
+ case inlineItalic:
+ out.WriteByte('*')
+ out.WriteString(escapeMarkdown(match.text))
+ out.WriteByte('*')
+ case inlineLink:
+ out.WriteByte('[')
+ out.WriteString(escapeLinkLabel(match.text))
+ out.WriteString("](")
+ out.WriteString(match.href)
+ out.WriteByte(')')
+ case inlineAutoLink:
+ out.WriteByte('<')
+ out.WriteString(match.href)
+ out.WriteByte('>')
+ }
+ last = match.end
+ }
+ out.WriteString(escapeMarkdown(line[last:]))
+ return out.String()
+}
+
+func escapeLinkLabel(text string) string {
+ var out strings.Builder
+ for _, r := range text {
+ switch r {
+ case '\\', '[', ']', '*', '_', '~', '<', '>', 0x60:
+ out.WriteByte('\\')
+ }
+ out.WriteRune(r)
+ }
+ return out.String()
+}
+
+func inlineMatches(line string) []inlineMatch {
+ var found []inlineMatch
+ found = appendPattern(found, line, codePattern, 0, inlineCode)
+ found = appendPattern(found, line, boldPattern, 1, inlineBold)
+ found = append(found, starItalicMatches(line)...)
+ found = append(found, underscoreMatches(line)...)
+ found = append(found, linkMatches(line)...)
+ found = append(found, bareURLMatches(line)...)
+ slices.SortFunc(found, func(left, right inlineMatch) int {
+ if left.start != right.start {
+ return left.start - right.start
+ }
+ return left.priority - right.priority
+ })
+ return found
+}
+
+func starItalicMatches(line string) []inlineMatch {
+ var found []inlineMatch
+ for cursor := 0; cursor < len(line); {
+ relative := strings.IndexByte(line[cursor:], '*')
+ if relative < 0 {
+ break
+ }
+ open := cursor + relative
+ closeRelative := strings.IndexByte(line[open+1:], '*')
+ if closeRelative < 0 {
+ break
+ }
+ close := open + 1 + closeRelative
+ content := line[open+1 : close]
+ if content != "" && !jsWhitespace(firstRune(content)) {
+ found = append(found, inlineMatch{
+ start: open, end: close + 1, priority: 2, kind: inlineItalic, text: content,
+ })
+ cursor = close + 1
+ continue
+ }
+ cursor = open + 1
+ }
+ return found
+}
+
+func appendPattern(found []inlineMatch, line string, pattern *regexp.Regexp, priority int, kind inlineKind) []inlineMatch {
+ for _, index := range pattern.FindAllStringSubmatchIndex(line, -1) {
+ found = append(found, inlineMatch{
+ start: index[0], end: index[1], priority: priority, kind: kind,
+ text: line[index[2]:index[3]],
+ })
+ }
+ return found
+}
+
+func underscoreMatches(line string) []inlineMatch {
+ var found []inlineMatch
+ for start := 0; start < len(line); {
+ relative := strings.IndexByte(line[start:], '_')
+ if relative < 0 {
+ break
+ }
+ open := start + relative
+ closeRelative := strings.IndexByte(line[open+1:], '_')
+ if closeRelative < 0 {
+ break
+ }
+ close := open + 1 + closeRelative
+ content := line[open+1 : close]
+ if content != "" && !jsWhitespace(firstRune(content)) && !jsWhitespace(lastRune(content)) &&
+ !wordBefore(line, open) && !wordAfter(line, close+1) {
+ found = append(found, inlineMatch{
+ start: open, end: close + 1, priority: 2, kind: inlineItalic, text: content,
+ })
+ start = close + 1
+ continue
+ }
+ start = open + 1
+ }
+ return found
+}
+
+func linkMatches(line string) []inlineMatch {
+ var found []inlineMatch
+ for cursor := 0; cursor < len(line); {
+ startRelative := strings.IndexByte(line[cursor:], '[')
+ if startRelative < 0 {
+ break
+ }
+ start := cursor + startRelative
+ endRelative := strings.IndexByte(line[start+1:], ']')
+ if endRelative < 0 {
+ break
+ }
+ labelEnd := start + 1 + endRelative
+ if labelEnd == start+1 || labelEnd+2 >= len(line) || line[labelEnd+1] != '(' {
+ cursor = labelEnd + 1
+ continue
+ }
+ hrefStart := labelEnd + 2
+ if !strings.HasPrefix(line[hrefStart:], "http://") && !strings.HasPrefix(line[hrefStart:], "https://") {
+ cursor = labelEnd + 1
+ continue
+ }
+ hrefEnd := hrefStart
+ for hrefEnd < len(line) && line[hrefEnd] != ')' {
+ r, size := utf8.DecodeRuneInString(line[hrefEnd:])
+ if jsWhitespace(r) {
+ break
+ }
+ hrefEnd += size
+ }
+ if hrefEnd >= len(line) || line[hrefEnd] != ')' {
+ cursor = hrefEnd + 1
+ continue
+ }
+ found = append(found, inlineMatch{
+ start: start, end: hrefEnd + 1, priority: 3, kind: inlineLink,
+ text: line[start+1 : labelEnd], href: line[hrefStart:hrefEnd],
+ })
+ cursor = hrefEnd + 1
+ }
+ return found
+}
+
+func bareURLMatches(line string) []inlineMatch {
+ var found []inlineMatch
+ for cursor := 0; cursor < len(line); {
+ start := nextURLStart(line, cursor)
+ if start < 0 {
+ break
+ }
+ end := start
+ for end < len(line) {
+ r, size := utf8.DecodeRuneInString(line[end:])
+ if jsWhitespace(r) || strings.ContainsRune("<>()\"'`", r) {
+ break
+ }
+ end += size
+ }
+ url := strings.TrimRight(line[start:end], ".,;:!?")
+ found = append(found, inlineMatch{
+ start: start, end: start + len(url), priority: 4,
+ kind: inlineAutoLink, text: url, href: url,
+ })
+ cursor = max(end, start+1)
+ }
+ return found
+}
+
+func nextURLStart(line string, cursor int) int {
+ http := strings.Index(line[cursor:], "http://")
+ https := strings.Index(line[cursor:], "https://")
+ switch {
+ case http < 0 && https < 0:
+ return -1
+ case http < 0:
+ return cursor + https
+ case https < 0:
+ return cursor + http
+ default:
+ return cursor + min(http, https)
+ }
+}
+
+func escapeMarkdown(text string) string {
+ var out strings.Builder
+ for _, r := range text {
+ if r >= '!' && r <= '~' && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
+ out.WriteByte('\\')
+ }
+ out.WriteRune(r)
+ }
+ return out.String()
+}
+
+func firstRune(text string) rune {
+ r, _ := utf8.DecodeRuneInString(text)
+ return r
+}
+
+func lastRune(text string) rune {
+ r, _ := utf8.DecodeLastRuneInString(text)
+ return r
+}
+
+func wordBefore(text string, index int) bool {
+ if index == 0 {
+ return false
+ }
+ r, _ := utf8.DecodeLastRuneInString(text[:index])
+ return asciiWord(r)
+}
+
+func wordAfter(text string, index int) bool {
+ if index >= len(text) {
+ return false
+ }
+ r, _ := utf8.DecodeRuneInString(text[index:])
+ return asciiWord(r)
+}
+
+func asciiWord(r rune) bool {
+ return r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9'
+}
+
+func jsWhitespace(r rune) bool {
+ switch r {
+ case '\t', '\n', '\v', '\f', '\r', ' ', '\u00a0', '\u1680', '\u2000', '\u2001', '\u2002', '\u2003',
+ '\u2004', '\u2005', '\u2006', '\u2007', '\u2008', '\u2009', '\u200a', '\u2028', '\u2029', '\u202f',
+ '\u205f', '\u3000', '\ufeff':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/tui/carddetail/markdown_test.go b/internal/tui/carddetail/markdown_test.go
new file mode 100644
index 0000000..cfd4e7f
--- /dev/null
+++ b/internal/tui/carddetail/markdown_test.go
@@ -0,0 +1,203 @@
+package carddetail
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+func TestParityMarkdownKeepsOnlyFrozenScope(t *testing.T) {
+ tick := string(rune(0x60))
+ fence := strings.Repeat(tick, 3)
+ input := strings.Join([]string{
+ "## Plan **now**",
+ "plain *italic* _also_ snake_case " + tick + "code" + tick + " [docs](https://example.com/x) https://example.com/y.",
+ "- bullet",
+ "12. ordered",
+ "#### not heading",
+ "> quote",
+ "~~strike~~",
+ "| table |",
+ "html",
+ fence + "go",
+ "raw **code**",
+ "~~~~",
+ fence,
+ }, "\n")
+
+ got := parityMarkdown(input)
+ for _, want := range []string{
+ "**Plan now**",
+ "*italic*",
+ "*also*",
+ "snake\\_case",
+ tick + "code" + tick,
+ "[docs](https://example.com/x)",
+ "",
+ "- bullet",
+ "12\\. ordered",
+ "\\#\\#\\#\\# not heading",
+ "\\> quote",
+ "\\~\\~strike\\~\\~",
+ "\\| table \\|",
+ "\\html\\<\\/b\\>",
+ "raw **code**\n~~~~\n",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("parity markdown missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func TestInlineMarkdownRejectsMalformedAndOverlappingSyntax(t *testing.T) {
+ tick := string(rune(0x60))
+ input := "**bold " + tick + "wins" + tick + "** [bad](javascript:alert(1)) [empty]() [open](https://example.com snake_case _x_ * nope*"
+ got := inlineMarkdown(input, false)
+ for _, want := range []string{"**bold \\" + tick + "wins\\" + tick + "**", "\\[bad\\]", "\\[empty\\]", "snake\\_case", "*x*", "\\* nope\\*"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("inline markdown missing %q: %s", want, got)
+ }
+ }
+
+ if got := inlineMarkdown("**heading**", true); got != "heading" {
+ t.Fatalf("bold inside flattened heading = %q", got)
+ }
+}
+
+func TestBlockAndBoundaryHelpers(t *testing.T) {
+ tests := []struct {
+ line string
+ heading int
+ markerEnd int
+ textStart int
+ }{
+ {line: "# one", heading: 1},
+ {line: "### three", heading: 3},
+ {line: "##\t Plan", heading: 2},
+ {line: "#### four"},
+ {line: "#nospace"},
+ {line: "1. one", markerEnd: 2, textStart: 3},
+ {line: "123. many", markerEnd: 4, textStart: 5},
+ {line: "12.\t many", markerEnd: 3, textStart: 6},
+ {line: "1234. too many"},
+ {line: ". none"},
+ }
+ for _, tt := range tests {
+ level, _, _ := heading(tt.line)
+ if level != tt.heading {
+ t.Errorf("heading(%q) = %d, want %d", tt.line, level, tt.heading)
+ }
+ markerEnd, textStart := orderedPrefix(tt.line)
+ if markerEnd != tt.markerEnd || textStart != tt.textStart {
+ t.Errorf("orderedPrefix(%q) = (%d, %d), want (%d, %d)", tt.line, markerEnd, textStart, tt.markerEnd, tt.textStart)
+ }
+ }
+
+ if got := safeFence([]string{"~", "~~~~", "plain"}); got != "~~~~~" {
+ t.Fatalf("safeFence = %q, want five tildes", got)
+ }
+ if firstRune("界x") != '界' || lastRune("x界") != '界' {
+ t.Fatal("rune helpers lost Unicode")
+ }
+ if !wordBefore("a_", 1) || wordBefore("_", 0) || !wordAfter("_a", 1) || wordAfter("_", 1) {
+ t.Fatal("word-boundary helpers returned the wrong result")
+ }
+}
+
+func TestParityMarkdownAcceptsWebWhitespace(t *testing.T) {
+ got := parityMarkdown("##\t Plan\n12.\t item")
+ for _, want := range []string{"**Plan**", "12\\. item"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("parity markdown missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func TestRenderMarkdownPreservesSourceOrdinals(t *testing.T) {
+ got := ansi.Strip(renderMarkdown("0. zero\n3. three\n9. nine\n001. padded", 60))
+ for _, want := range []string{"0. zero", "3. three", "9. nine", "001. padded"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("rendered markdown missing source marker %q:\n%s", want, got)
+ }
+ }
+ lines := "\n" + strings.TrimSpace(got) + "\n"
+ if strings.Contains(lines, "\n4. three\n") || strings.Contains(lines, "\n1. padded\n") {
+ t.Fatalf("rendered markdown renumbered source ordinals:\n%s", got)
+ }
+}
+
+func TestFrozenJavaScriptBoundaryParity(t *testing.T) {
+ got := inlineMarkdown("é_x_ _y_界 A_z_ _q_9", false)
+ want := "é*x* *y*界 A\\_z\\_ \\_q\\_9"
+ if got != want {
+ t.Fatalf("ASCII word-boundary parity = %q, want %q", got, want)
+ }
+
+ nbsp := "\u00a0"
+ got = parityMarkdown("https://a.test/x" + nbsp + "tail")
+ if !strings.Contains(got, ""+nbsp+"tail") {
+ t.Fatalf("bare URL crossed JavaScript whitespace: %q", got)
+ }
+ got = inlineMarkdown("[bad](https://x.test/"+nbsp+"tail) [ok](https://ok.test)", false)
+ if strings.Contains(got, "[bad](") || !strings.Contains(got, "[ok](https://ok.test)") {
+ t.Fatalf("explicit-link whitespace parity = %q", got)
+ }
+ if _, _, ok := heading("#\u0085text"); ok {
+ t.Fatal("U+0085 incorrectly matched JavaScript whitespace")
+ }
+ if marker, _ := orderedPrefix("1.\u0085text"); marker != 0 {
+ t.Fatal("U+0085 incorrectly started an ordered item")
+ }
+}
+
+func TestRenderMarkdownPreservesJavaScriptControlWhitespace(t *testing.T) {
+ got := ansi.Strip(renderMarkdown("*\vx*\n#\vheading\n1.\fitem", 60))
+ for _, want := range []string{"* x*", "heading", "1. item"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("rendered control whitespace missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func TestNodeDerivedLineSeparatorVectors(t *testing.T) {
+ for _, tt := range []struct {
+ line string
+ headingText string
+ ordinalText string
+ }{
+ {line: "#\u2028head", headingText: "head"},
+ {line: "1.\u2029item", ordinalText: "item"},
+ {line: "# head\u2028tail"},
+ {line: "# head\u2029tail"},
+ {line: "# head\u2028"},
+ {line: "1. item\u2028tail"},
+ {line: "1. item\u2029tail"},
+ {line: "1. item\u2029"},
+ } {
+ _, headingText, headingOK := heading(tt.line)
+ if headingOK != (tt.headingText != "") || headingText != tt.headingText {
+ t.Errorf("heading(%q) = (%q, %v), want (%q, %v)", tt.line, headingText, headingOK, tt.headingText, tt.headingText != "")
+ }
+ markerEnd, textStart := orderedPrefix(tt.line)
+ ordinalOK := markerEnd > 0
+ ordinalText := ""
+ if ordinalOK {
+ ordinalText = tt.line[textStart:]
+ }
+ if ordinalOK != (tt.ordinalText != "") || ordinalText != tt.ordinalText {
+ t.Errorf("orderedPrefix(%q) = (%q, %v), want (%q, %v)", tt.line, ordinalText, ordinalOK, tt.ordinalText, tt.ordinalText != "")
+ }
+ }
+}
+
+func TestParityMarkdownUnterminatedFenceAndEmptyInput(t *testing.T) {
+ fence := strings.Repeat(string(rune(0x60)), 3)
+ got := parityMarkdown(fence + "\ncode\n~~~")
+ if !strings.Contains(got, "~~~~\ncode\n~~~\n~~~~") {
+ t.Fatalf("unterminated fence was not preserved safely:\n%s", got)
+ }
+ if got := parityMarkdown(""); got != "" {
+ t.Fatalf("empty markdown = %q", got)
+ }
+}
diff --git a/internal/tui/carddetail/model.go b/internal/tui/carddetail/model.go
new file mode 100644
index 0000000..6b3b637
--- /dev/null
+++ b/internal/tui/carddetail/model.go
@@ -0,0 +1,535 @@
+// Package carddetail renders the read-only full-card overlay for the TUI.
+package carddetail
+
+import (
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/glamour/v2"
+ "charm.land/glamour/v2/styles"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/RandomCodeSpace/kb/internal/board"
+ "github.com/RandomCodeSpace/kb/internal/store"
+)
+
+const (
+ maxPaneWidth = 92
+ defaultWidth = 80
+ defaultHeight = 24
+)
+
+// Reader is the store projection needed to enrich a board task for display.
+type Reader interface {
+ Comments(user, taskRef string) ([]store.Comment, error)
+ TaskLinks(user, taskID string) (store.TaskLinks, error)
+ Tombstone(user, taskID string) (store.Tombstone, bool, error)
+}
+
+type detailLoadedMsg struct {
+ taskID string
+ generation uint64
+ comments []store.Comment
+ links store.TaskLinks
+ tombstone *store.Tombstone
+ commentsErr error
+ linksErr error
+ tombstoneErr error
+}
+
+type markdownRenderer func(source string, width int) string
+
+// Model owns the overlay's task snapshot, enriched detail, and scroll state.
+type Model struct {
+ reader Reader
+ user string
+ task board.Task
+ comments []store.Comment
+ links store.TaskLinks
+ tombstone *store.Tombstone
+ open bool
+ loading bool
+ reloadPending bool
+ commentsErr error
+ linksErr error
+ tombstoneErr error
+ scroll int
+ generation uint64
+ width int
+ height int
+ renderMarkdown markdownRenderer
+ bodyLines []string
+ bodyWidth int
+}
+
+// New creates a closed detail pane. A nil reader still shows board-resident
+// task fields; enrichment is simply unavailable to lightweight model tests.
+func New(reader Reader, user string) Model {
+ return Model{
+ reader: reader, user: user, width: defaultWidth, height: defaultHeight,
+ renderMarkdown: renderMarkdown,
+ }
+}
+
+// IsOpen reports whether the overlay currently owns input and rendering.
+func (m Model) IsOpen() bool { return m.open }
+
+// TaskID returns the displayed task's durable ID, or empty while closed.
+func (m Model) TaskID() string {
+ if !m.open {
+ return ""
+ }
+ return m.task.ID
+}
+
+// Open resets the pane to task and returns the asynchronous enrichment load.
+func (m *Model) Open(task board.Task) tea.Cmd {
+ m.task = task
+ m.comments = nil
+ m.links = store.TaskLinks{}
+ m.tombstone = nil
+ m.open = true
+ m.loading = false
+ m.reloadPending = false
+ m.commentsErr = nil
+ m.linksErr = nil
+ m.tombstoneErr = nil
+ m.scroll = 0
+ if m.reader == nil {
+ m.rebuildBody()
+ return nil
+ }
+ return m.startLoad()
+}
+
+// Refresh replaces the board-resident snapshot and serializes enrichment IO.
+// Repeated refreshes during a load collapse into one successor for the latest
+// task snapshot.
+func (m *Model) Refresh(task board.Task) tea.Cmd {
+ if !m.open || task.ID != m.task.ID {
+ return nil
+ }
+ m.task = task
+ if m.reader == nil {
+ m.rebuildBody()
+ return nil
+ }
+ if m.loading {
+ m.reloadPending = true
+ m.rebuildBody()
+ return nil
+ }
+ return m.startLoad()
+}
+
+// Close dismisses the pane and invalidates any in-flight result by clearing
+// the current task identity.
+func (m *Model) Close() {
+ m.generation++
+ m.open = false
+ m.loading = false
+ m.reloadPending = false
+ m.task = board.Task{}
+ m.bodyLines = nil
+ m.bodyWidth = 0
+ m.scroll = 0
+}
+
+// Resize updates the viewport used to bound persistent scroll state.
+func (m *Model) Resize(width, height int) {
+ m.width = max(width, 1)
+ m.height = max(height, 1)
+ innerWidth, _, _ := paneGeometry(m.width, m.height)
+ if m.open && (m.bodyLines == nil || m.bodyWidth != innerWidth) {
+ m.rebuildBody()
+ return
+ }
+ m.clampScroll()
+}
+
+// Update handles enrichment results and overlay scrolling.
+func (m *Model) Update(message tea.Msg) tea.Cmd {
+ if !m.open {
+ return nil
+ }
+ switch msg := message.(type) {
+ case detailLoadedMsg:
+ if msg.taskID != m.task.ID || msg.generation != m.generation {
+ return nil
+ }
+ if m.reloadPending {
+ m.reloadPending = false
+ return m.startLoad()
+ }
+ m.loading = false
+ m.comments = msg.comments
+ m.links = msg.links
+ m.tombstone = msg.tombstone
+ m.commentsErr = msg.commentsErr
+ m.linksErr = msg.linksErr
+ m.tombstoneErr = msg.tombstoneErr
+ m.rebuildBody()
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "up", "k", "pgup":
+ m.scroll = max(0, m.scroll-scrollAmount(msg.String()))
+ case "down", "j", "pgdown":
+ m.scroll += scrollAmount(msg.String())
+ case "home", "g":
+ m.scroll = 0
+ }
+ }
+ m.clampScroll()
+ return nil
+}
+
+func scrollAmount(key string) int {
+ if key == "pgup" || key == "pgdown" {
+ return 8
+ }
+ return 1
+}
+
+func (m Model) load(taskID string, generation uint64) tea.Cmd {
+ return func() tea.Msg {
+ comments, commentsErr := m.reader.Comments(m.user, taskID)
+ links, linksErr := m.reader.TaskLinks(m.user, taskID)
+ tombstone, found, tombstoneErr := m.reader.Tombstone(m.user, taskID)
+ var killed *store.Tombstone
+ if found {
+ killed = &tombstone
+ }
+ return detailLoadedMsg{
+ taskID: taskID, generation: generation, comments: comments, links: links, tombstone: killed,
+ commentsErr: commentsErr, linksErr: linksErr, tombstoneErr: tombstoneErr,
+ }
+ }
+}
+
+func (m *Model) startLoad() tea.Cmd {
+ m.generation++
+ m.loading = true
+ m.commentsErr = nil
+ m.linksErr = nil
+ m.tombstoneErr = nil
+ m.rebuildBody()
+ return m.load(m.task.ID, m.generation)
+}
+
+// View renders a centered bordered pane sized for the current terminal.
+func (m *Model) View(width, height int) string {
+ if !m.open {
+ return ""
+ }
+ width = max(width, 1)
+ height = max(height, 1)
+ frame, _, _ := m.frame(width, height)
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, frame)
+}
+
+// Overlay composes the pane over the board without making carddetail own the
+// board renderer. Input routing remains the root model's responsibility.
+func (m *Model) Overlay(background string, width, height int) string {
+ if !m.open {
+ return background
+ }
+ width = max(width, 1)
+ height = max(height, 1)
+ frame, paneWidth, paneHeight := m.frame(width, height)
+ x := max((width-paneWidth)/2, 0)
+ y := max((height-paneHeight)/2, 0)
+ return lipgloss.NewCompositor(
+ lipgloss.NewLayer(background),
+ lipgloss.NewLayer(frame).X(x).Y(y).Z(1),
+ ).Render()
+}
+
+func (m *Model) frame(width, height int) (string, int, int) {
+ width = max(width, 1)
+ height = max(height, 1)
+ m.ensureBody(width, height)
+ innerWidth, innerHeight, paneHeight := paneGeometry(width, height)
+
+ lines := m.bodyLines
+ maxScroll := max(0, len(lines)-innerHeight)
+ start := min(m.scroll, maxScroll)
+ end := min(start+innerHeight, len(lines))
+ visibleLines := append([]string(nil), lines[start:end]...)
+ for i := range visibleLines {
+ visibleLines[i] = ansi.Truncate(visibleLines[i], innerWidth, "")
+ }
+ visible := strings.Join(visibleLines, "\n")
+ visible = lipgloss.NewStyle().Width(innerWidth).Height(innerHeight).Render(visible)
+ footer := "esc close ↑/↓ scroll"
+ if maxScroll > 0 {
+ footer = fmt.Sprintf("%s %d/%d", footer, start+1, maxScroll+1)
+ }
+ content := visible + "\n" + ansi.Truncate(footer, innerWidth, "…")
+ frame := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ Padding(0, 1).
+ Width(innerWidth).
+ Height(paneHeight - 2).
+ Render(content)
+ frame = fitTerminal(frame, width, height)
+ return frame, lipgloss.Width(frame), lipgloss.Height(frame)
+}
+
+func paneGeometry(width, height int) (innerWidth, innerHeight, paneHeight int) {
+ width = max(width, 1)
+ height = max(height, 1)
+ paneWidth := min(max(width-4, 12), maxPaneWidth, width)
+ paneHeight = min(max(min(height-2, height), 5), height)
+ return max(paneWidth-4, 1), max(paneHeight-4, 1), paneHeight
+}
+
+func (m *Model) clampScroll() {
+ if !m.open {
+ m.scroll = 0
+ return
+ }
+ m.scroll = min(max(m.scroll, 0), m.maxScroll())
+}
+
+func (m Model) maxScroll() int {
+ _, innerHeight, _ := paneGeometry(m.width, m.height)
+ return max(0, len(m.bodyLines)-innerHeight)
+}
+
+func (m *Model) ensureBody(width, height int) {
+ m.width = max(width, 1)
+ m.height = max(height, 1)
+ innerWidth, _, _ := paneGeometry(m.width, m.height)
+ if m.bodyLines == nil || m.bodyWidth != innerWidth {
+ m.rebuildBody()
+ return
+ }
+ m.clampScroll()
+}
+
+func (m *Model) rebuildBody() {
+ if !m.open {
+ m.bodyLines = nil
+ m.bodyWidth = 0
+ m.scroll = 0
+ return
+ }
+ innerWidth, _, _ := paneGeometry(m.width, m.height)
+ body := m.renderBody(innerWidth)
+ m.bodyLines = strings.Split(strings.TrimRight(body, "\n"), "\n")
+ m.bodyWidth = innerWidth
+ m.clampScroll()
+}
+
+func fitTerminal(rendered string, width, height int) string {
+ lines := strings.Split(rendered, "\n")
+ if len(lines) > height {
+ lines = lines[:height]
+ }
+ for i := range lines {
+ lines[i] = ansi.Truncate(lines[i], width, "")
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (m Model) renderBody(width int) string {
+ title := strings.TrimSpace(safeText(m.task.Title, false))
+ if m.task.Emoji != "" {
+ title = safeText(m.task.Emoji, false) + " " + title
+ }
+ if m.task.Seq > 0 {
+ title += fmt.Sprintf(" #%d", m.task.Seq)
+ }
+ sections := []string{lipgloss.NewStyle().Bold(true).Render(title)}
+ sections = append(sections, m.metadata())
+ if tags := regularTags(m.task.Tags); len(tags) > 0 {
+ sections = append(sections, "labels "+strings.Join(tags, " "))
+ }
+ if links := importLinks(m.task.Tags); len(links) > 0 {
+ sections = append(sections, "links "+strings.Join(links, " "))
+ }
+ if m.tombstone != nil {
+ sections = append(sections, killedContext(*m.tombstone))
+ }
+ if m.tombstoneErr != nil {
+ sections = append(sections, "killed context error: "+safeText(m.tombstoneErr.Error(), false))
+ }
+ if strings.TrimSpace(m.task.Desc) != "" {
+ sections = append(sections, m.markdown(m.task.Desc, width))
+ }
+ if len(m.task.Checks) > 0 {
+ sections = append(sections, renderChecklist(m.task.Checks))
+ }
+ if refs := renderTaskLinks(m.links); refs != "" {
+ sections = append(sections, refs)
+ }
+ if m.linksErr != nil {
+ sections = append(sections, "blocker links error: "+safeText(m.linksErr.Error(), false))
+ }
+ if m.loading {
+ sections = append(sections, "loading comments and context...")
+ } else if m.commentsErr != nil {
+ sections = append(sections, "comments error: "+safeText(m.commentsErr.Error(), false))
+ } else {
+ if len(m.comments) > 0 {
+ sections = append(sections, renderCommentsWith(m.comments, width, m.markdown))
+ } else {
+ sections = append(sections, "comments none")
+ }
+ }
+ return strings.Join(sections, "\n\n")
+}
+
+func (m Model) markdown(source string, width int) string {
+ if m.renderMarkdown == nil {
+ return renderMarkdown(source, width)
+ }
+ return m.renderMarkdown(source, width)
+}
+
+func (m Model) metadata() string {
+ primary := []string{fmt.Sprintf("status %s", m.task.Status), fmt.Sprintf("priority %d", m.task.Prio)}
+ if m.task.Blocked {
+ primary = append(primary, "blocked")
+ }
+ secondary := make([]string, 0, 2)
+ if m.task.Due != "" {
+ secondary = append(secondary, "due "+m.task.Due)
+ }
+ if m.task.Effort != "" {
+ secondary = append(secondary, "effort "+m.task.Effort)
+ }
+ if len(secondary) == 0 {
+ return strings.Join(primary, " ")
+ }
+ return strings.Join(primary, " ") + "\n" + strings.Join(secondary, " ")
+}
+
+func regularTags(tags []string) []string {
+ var out []string
+ for _, tag := range tags {
+ if !strings.HasPrefix(tag, "link::") {
+ out = append(out, "["+safeText(tag, false)+"]")
+ }
+ }
+ return out
+}
+
+func importLinks(tags []string) []string {
+ seen := make(map[string]bool)
+ var out []string
+ for _, tag := range tags {
+ if !strings.HasPrefix(tag, "link::") {
+ continue
+ }
+ link := strings.TrimSpace(strings.TrimPrefix(tag, "link::"))
+ if link != "" && !seen[link] {
+ seen[link] = true
+ out = append(out, "["+safeText(link, false)+"]")
+ }
+ }
+ return out
+}
+
+func killedContext(tombstone store.Tombstone) string {
+ date := tombstone.KilledAt
+ if stamp, err := time.Parse(time.RFC3339Nano, tombstone.KilledAt); err == nil {
+ date = stamp.UTC().Format("2 Jan 2006")
+ }
+ return fmt.Sprintf("killed %s\n%s", safeText(date, false), safeText(tombstone.Reason, false))
+}
+
+func renderMarkdown(source string, width int) string {
+ style := styles.DarkStyleConfig
+ zero := uint(0)
+ style.Document.Margin = &zero
+ renderer, err := glamour.NewTermRenderer(
+ glamour.WithStyles(style),
+ glamour.WithWordWrap(max(width, 1)),
+ )
+ if err != nil {
+ return safeText(source, true)
+ }
+ rendered, err := renderer.Render(parityMarkdown(safeText(source, true)))
+ if err != nil {
+ return safeText(source, true)
+ }
+ return strings.Trim(rendered, "\r\n")
+}
+
+func renderChecklist(checks []board.Check) string {
+ lines := []string{"checklist"}
+ for _, check := range checks {
+ mark := "☐"
+ if check.Done {
+ mark = "☑"
+ }
+ lines = append(lines, fmt.Sprintf(" %s %s", mark, safeText(check.Text, false)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func renderTaskLinks(links store.TaskLinks) string {
+ var lines []string
+ if len(links.Blocks) > 0 {
+ lines = append(lines, "blocks "+taskChips(links.Blocks))
+ }
+ if len(links.BlockedBy) > 0 {
+ lines = append(lines, "blocked by "+taskChips(links.BlockedBy))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func taskChips(tasks []board.Task) string {
+ chips := make([]string, 0, len(tasks))
+ for _, task := range tasks {
+ ref := task.ID
+ if task.Seq > 0 {
+ ref = fmt.Sprintf("#%d", task.Seq)
+ }
+ chips = append(chips, fmt.Sprintf("[%s %s]", ref, task.Status))
+ }
+ return strings.Join(chips, " ")
+}
+
+func renderComments(comments []store.Comment, width int) string {
+ return renderCommentsWith(comments, width, renderMarkdown)
+}
+
+func renderCommentsWith(comments []store.Comment, width int, renderer markdownRenderer) string {
+ sections := []string{fmt.Sprintf("comments %d", len(comments))}
+ for _, comment := range comments {
+ date := comment.CreatedAt.UTC().Format("2 Jan 2006")
+ header := fmt.Sprintf("c%d %s %s", comment.ID, safeText(comment.Author, false), date)
+ sections = append(sections, header+"\n"+renderer(comment.Body, width))
+ }
+ return strings.Join(sections, "\n\n")
+}
+
+func safeText(text string, keepNewlines bool) string {
+ text = ansi.Strip(text)
+ var out strings.Builder
+ for _, r := range text {
+ if r == '\t' {
+ out.WriteString(" ")
+ continue
+ }
+ if r == '\v' || r == '\f' || r == '\r' {
+ out.WriteByte(' ')
+ continue
+ }
+ if keepNewlines && r == '\n' {
+ out.WriteRune(r)
+ continue
+ }
+ if unicode.IsControl(r) {
+ continue
+ }
+ out.WriteRune(r)
+ }
+ return out.String()
+}
diff --git a/internal/tui/carddetail/model_test.go b/internal/tui/carddetail/model_test.go
new file mode 100644
index 0000000..350e1da
--- /dev/null
+++ b/internal/tui/carddetail/model_test.go
@@ -0,0 +1,399 @@
+package carddetail
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/exp/golden"
+
+ "github.com/RandomCodeSpace/kb/internal/board"
+ "github.com/RandomCodeSpace/kb/internal/store"
+)
+
+type stubReader struct {
+ comments []store.Comment
+ links store.TaskLinks
+ tombstone store.Tombstone
+ found bool
+ commentsErr error
+ linksErr error
+ tombstoneErr error
+}
+
+func (s stubReader) Comments(string, string) ([]store.Comment, error) {
+ return s.comments, s.commentsErr
+}
+
+func (s stubReader) TaskLinks(string, string) (store.TaskLinks, error) {
+ return s.links, s.linksErr
+}
+
+func (s stubReader) Tombstone(string, string) (store.Tombstone, bool, error) {
+ return s.tombstone, s.found, s.tombstoneErr
+}
+
+func fullTask() board.Task {
+ return board.Task{
+ ID: "task-1", Seq: 7, Emoji: "🧭", Title: "Map it", Desc: "## Plan\n- **first**\nhttps://example.com",
+ Status: board.StatusCancelled, Blocked: true, Prio: 1, Due: "2026-08-19", Effort: "M",
+ Tags: []string{"type::feature", "link::github#86", "link::github#86", "link::"},
+ Checks: []board.Check{{Text: "done", Done: true}, {Text: "left"}},
+ }
+}
+
+func TestModelLoadsAndRendersFullDetail(t *testing.T) {
+ stamp := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC)
+ reader := stubReader{
+ comments: []store.Comment{{ID: 3, Author: "alice", Body: "looks **good**", CreatedAt: stamp}},
+ links: store.TaskLinks{
+ Blocks: []board.Task{{Seq: 9, Status: board.StatusTodo}},
+ BlockedBy: []board.Task{{ID: "legacy", Status: board.StatusDone}},
+ },
+ tombstone: store.Tombstone{TaskID: "task-1", Reason: "superseded", KilledAt: stamp.Format(time.RFC3339Nano)},
+ found: true,
+ }
+ m := New(reader, "alice")
+ if m.IsOpen() || m.TaskID() != "" || m.View(80, 24) != "" {
+ t.Fatal("new detail pane was not closed")
+ }
+ command := m.Open(fullTask())
+ if command == nil || !m.IsOpen() || m.TaskID() != "task-1" || !m.loading {
+ t.Fatalf("open state = %+v, command nil=%v", m, command == nil)
+ }
+ if body := ansi.Strip(m.renderBody(72)); !strings.Contains(body, "loading comments and context") {
+ t.Fatalf("loading body:\n%s", body)
+ }
+ m.Update(command())
+ body := ansi.Strip(m.renderBody(72))
+ for _, want := range []string{
+ "🧭 Map it #7", "status cancelled", "priority 1", "due 2026-08-19", "effort M", "blocked",
+ "[type::feature]", "[github#86]", "killed 17 Aug 2026", "superseded", "Plan", "first",
+ "checklist", "☑ done", "☐ left", "blocks [#9 todo]", "blocked by [legacy done]",
+ "comments 1", "c3 alice 17 Aug 2026", "looks", "good",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("detail body missing %q:\n%s", want, body)
+ }
+ }
+ if strings.Count(body, "[github#86]") != 1 {
+ t.Errorf("link chips were not deduplicated:\n%s", body)
+ }
+
+ view := m.View(80, 20)
+ for _, line := range strings.Split(view, "\n") {
+ if ansi.StringWidth(line) > 80 {
+ t.Fatalf("overlay line wider than terminal: %d: %q", ansi.StringWidth(line), line)
+ }
+ }
+}
+
+func TestModelHandlesErrorsStaleLoadsScrollAndClose(t *testing.T) {
+ loadErr := errors.New("comments broke")
+ m := New(stubReader{commentsErr: loadErr, linksErr: errors.New("links broke")}, "u")
+ command := m.Open(board.Task{ID: "current", Title: "Current", Status: board.StatusTodo})
+ m.Update(detailLoadedMsg{taskID: "stale", comments: []store.Comment{{ID: 1}}})
+ if !m.loading {
+ t.Fatal("stale result changed loading state")
+ }
+ m.Update(command())
+ if m.loading || !errors.Is(m.commentsErr, loadErr) || m.linksErr == nil {
+ t.Fatalf("load error state = loading %v, comments %v, links %v", m.loading, m.commentsErr, m.linksErr)
+ }
+ if body := ansi.Strip(m.renderBody(40)); !strings.Contains(body, "comments error:") || !strings.Contains(body, "blocker links error:") {
+ t.Fatalf("error body:\n%s", body)
+ }
+
+ for _, key := range []tea.KeyPressMsg{
+ {Code: tea.KeyDown}, {Code: 'j'}, {Code: tea.KeyPgDown},
+ {Code: tea.KeyUp}, {Code: 'k'}, {Code: tea.KeyPgUp},
+ } {
+ m.Update(key)
+ }
+ m.scroll = 10
+ m.Update(tea.KeyPressMsg{Code: tea.KeyHome})
+ if m.scroll != 0 {
+ t.Fatalf("home scroll = %d", m.scroll)
+ }
+ m.scroll = 10
+ m.Update(tea.KeyPressMsg{Code: 'g'})
+ if m.scroll != 0 || scrollAmount("pgdown") != 8 || scrollAmount("down") != 1 {
+ t.Fatal("scroll controls returned the wrong amount")
+ }
+
+ m.Close()
+ if m.IsOpen() || m.TaskID() != "" || m.Update(command()) != nil {
+ t.Fatal("closed pane accepted a late load")
+ }
+}
+
+func TestModelRendersIndependentEnrichmentResults(t *testing.T) {
+ stamp := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC)
+ task := board.Task{ID: "task", Title: "Task", Status: board.StatusTodo}
+
+ commentsFailed := New(stubReader{
+ comments: []store.Comment{{ID: 8, Author: "partial", Body: "must not render", CreatedAt: stamp}},
+ commentsErr: errors.New("comments unavailable"),
+ links: store.TaskLinks{Blocks: []board.Task{{Seq: 9, Status: board.StatusDoing}}},
+ tombstone: store.Tombstone{TaskID: "task", Reason: "replaced", KilledAt: stamp.Format(time.RFC3339Nano)},
+ found: true,
+ }, "u")
+ commentsFailed.Update(commentsFailed.Open(task)())
+ body := ansi.Strip(commentsFailed.renderBody(60))
+ for _, want := range []string{"blocks [#9 doing]", "killed 17 Aug 2026", "comments error: comments unavailable"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("comments failure hid %q:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, "comments none") || strings.Contains(body, "must not render") {
+ t.Fatalf("failed comments read rendered a successful comments state:\n%s", body)
+ }
+
+ contextFailed := New(stubReader{
+ comments: []store.Comment{{ID: 1, Author: "alice", Body: "available", CreatedAt: stamp}},
+ linksErr: errors.New("links unavailable"),
+ tombstoneErr: errors.New("killed unavailable"),
+ }, "u")
+ contextFailed.Update(contextFailed.Open(task)())
+ body = ansi.Strip(contextFailed.renderBody(60))
+ for _, want := range []string{"available", "blocker links error: links unavailable", "killed context error: killed unavailable"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("context failure hid %q:\n%s", want, body)
+ }
+ }
+}
+
+func TestModelRejectsStaleLoadForReopenedTask(t *testing.T) {
+ m := New(stubReader{}, "u")
+ task := board.Task{ID: "same", Title: "Same", Status: board.StatusTodo}
+ first := m.Open(task)
+ second := m.Open(task)
+
+ m.Update(first())
+ if !m.loading {
+ t.Fatal("stale same-task result changed loading state")
+ }
+ m.Update(second())
+ if m.loading {
+ t.Fatal("current same-task result did not finish loading")
+ }
+}
+
+type countingDetailReader struct{ loads int }
+
+func (r *countingDetailReader) Comments(string, string) ([]store.Comment, error) {
+ r.loads++
+ return []store.Comment{{ID: r.loads, Author: "load", Body: fmt.Sprintf("version %d", r.loads)}}, nil
+}
+
+func (*countingDetailReader) TaskLinks(string, string) (store.TaskLinks, error) {
+ return store.TaskLinks{}, nil
+}
+
+func (*countingDetailReader) Tombstone(string, string) (store.Tombstone, bool, error) {
+ return store.Tombstone{}, false, nil
+}
+
+func TestRefreshCoalescesEnrichmentLoads(t *testing.T) {
+ reader := &countingDetailReader{}
+ m := New(reader, "u")
+ first := m.Open(board.Task{ID: "same", Title: "first", Status: board.StatusTodo})
+ if command := m.Refresh(board.Task{ID: "same", Title: "second", Status: board.StatusDoing}); command != nil {
+ t.Fatal("refresh overlapped the active enrichment load")
+ }
+ if command := m.Refresh(board.Task{ID: "same", Title: "latest", Status: board.StatusDone}); command != nil {
+ t.Fatal("second refresh overlapped the active enrichment load")
+ }
+
+ successor := m.Update(first())
+ if successor == nil || !m.loading || m.reloadPending || len(m.comments) != 0 {
+ t.Fatalf("coalesced first result = loading %v pending %v comments %v command %v", m.loading, m.reloadPending, m.comments, successor)
+ }
+ m.Update(successor())
+ if m.loading || reader.loads != 2 || m.task.Title != "latest" || len(m.comments) != 1 || m.comments[0].Body != "version 2" {
+ t.Fatalf("coalesced successor = model %+v loads %d", m, reader.loads)
+ }
+
+ late := m.Refresh(board.Task{ID: "same", Title: "closed", Status: board.StatusDone})
+ m.Close()
+ if command := m.Update(late()); command != nil || m.IsOpen() {
+ t.Fatal("closed detail restarted a pending refresh")
+ }
+}
+
+func TestNilReaderAndRenderingHelpers(t *testing.T) {
+ m := New(nil, "u")
+ task := board.Task{ID: "id", Title: "Bare", Status: board.StatusTodo, Prio: 3}
+ if command := m.Open(task); command != nil || m.loading {
+ t.Fatalf("nil-reader open = command %v loading %v", command, m.loading)
+ }
+ body := ansi.Strip(m.renderBody(40))
+ if !strings.Contains(body, "comments none") {
+ t.Fatalf("nil-reader body:\n%s", body)
+ }
+ if got := m.View(1, 1); got == "" {
+ t.Fatal("tiny overlay was empty")
+ }
+
+ if got := regularTags([]string{"a", "link::x"}); len(got) != 1 || got[0] != "[a]" {
+ t.Fatalf("regularTags = %v", got)
+ }
+ if got := importLinks([]string{"link:: x ", "link::x", "link::"}); len(got) != 1 || got[0] != "[x]" {
+ t.Fatalf("importLinks = %v", got)
+ }
+ if got := killedContext(store.Tombstone{Reason: "why", KilledAt: "bad"}); got != "killed bad\nwhy" {
+ t.Fatalf("killedContext invalid date = %q", got)
+ }
+ if got := renderChecklist(nil); got != "checklist" {
+ t.Fatalf("empty checklist = %q", got)
+ }
+ if got := renderTaskLinks(store.TaskLinks{}); got != "" {
+ t.Fatalf("empty task links = %q", got)
+ }
+ if got := taskChips([]board.Task{{ID: "id", Status: board.StatusDoing}}); got != "[id doing]" {
+ t.Fatalf("fallback task chip = %q", got)
+ }
+ if got := renderComments([]store.Comment{{ID: 1, Author: "a", Body: "body"}}, 0); !strings.Contains(ansi.Strip(got), "1 Jan 0001") {
+ t.Fatalf("zero-time comment = %q", ansi.Strip(got))
+ }
+ if got := safeText("ok\x1b[31m red\a\nnext", true); got != "ok red\nnext" {
+ t.Fatalf("safeText = %q", got)
+ }
+}
+
+func TestViewClampsScrollToContent(t *testing.T) {
+ m := New(nil, "u")
+ task := fullTask()
+ task.Desc = strings.Repeat("line\n", 40)
+ m.Resize(40, 10)
+ m.Open(task)
+ for range 100 {
+ m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown})
+ }
+ if m.scroll != m.maxScroll() {
+ t.Fatalf("stored scroll = %d, max = %d", m.scroll, m.maxScroll())
+ }
+ before := m.scroll
+ m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+ if m.scroll != before-1 {
+ t.Fatalf("up from bottom = %d, want %d", m.scroll, before-1)
+ }
+ m.Resize(40, 100)
+ if m.scroll != m.maxScroll() {
+ t.Fatalf("resize scroll = %d, max = %d", m.scroll, m.maxScroll())
+ }
+ m.Refresh(board.Task{ID: task.ID, Title: task.Title, Desc: "short", Status: task.Status})
+ if m.scroll != 0 {
+ t.Fatalf("shorter content scroll = %d", m.scroll)
+ }
+ view := ansi.Strip(m.View(40, 10))
+ if !strings.Contains(view, "esc close") || !strings.Contains(view, "/") {
+ t.Fatalf("scrolled overlay footer missing:\n%s", view)
+ }
+}
+
+func TestCardDetailGolden(t *testing.T) {
+ m := New(nil, "default")
+ task := fullTask()
+ task.Status = board.StatusDoing
+ m.Open(task)
+ lines := strings.Split(ansi.Strip(m.View(60, 20)), "\n")
+ for i := range lines {
+ lines[i] = strings.TrimSpace(lines[i])
+ }
+ golden.RequireEqual(t, strings.Trim(strings.Join(lines, "\n"), "\n")+"\n")
+}
+
+func TestOverlayKeepsBoardAroundPane(t *testing.T) {
+ m := New(nil, "u")
+ background := strings.Repeat("b", 30) + "\n" + strings.Repeat("b", 30)
+ if got := m.Overlay(background, 30, 2); got != background {
+ t.Fatalf("closed overlay changed background: %q", got)
+ }
+ m.Open(board.Task{ID: "id", Title: "detail", Status: board.StatusTodo})
+ got := ansi.Strip(m.Overlay(background, 30, 8))
+ if !strings.Contains(got, "detail") || !strings.Contains(got, "bbbb") {
+ t.Fatalf("composed overlay lost pane or board:\n%s", got)
+ }
+}
+
+func TestViewFitsTinyTerminal(t *testing.T) {
+ m := New(nil, "u")
+ m.Open(board.Task{ID: "id", Title: "detail", Status: board.StatusTodo})
+ for _, size := range [][2]int{{1, 1}, {2, 3}, {4, 3}} {
+ view := m.View(size[0], size[1])
+ lines := strings.Split(view, "\n")
+ if len(lines) > size[1] {
+ t.Fatalf("%dx%d view has %d lines", size[0], size[1], len(lines))
+ }
+ for _, line := range lines {
+ if got := ansi.StringWidth(line); got > size[0] {
+ t.Fatalf("%dx%d view line width = %d: %q", size[0], size[1], got, line)
+ }
+ }
+ }
+}
+
+func TestHostileFencedTabsStayInsideBorder(t *testing.T) {
+ m := New(nil, "u")
+ m.Open(board.Task{
+ ID: "id", Title: "tabs", Status: board.StatusTodo,
+ Desc: "```\n\t~~~~~~\t界界界界界\n```",
+ })
+ view := ansi.Strip(m.View(20, 20))
+ if strings.ContainsRune(view, '\t') {
+ t.Fatalf("view retained a tab:\n%s", view)
+ }
+ if !strings.Contains(view, "~~~~") {
+ t.Fatalf("hostile fenced code was not visible:\n%s", view)
+ }
+ lines := strings.Split(view, "\n")
+ if len(lines) > 20 {
+ t.Fatalf("view has %d lines:\n%s", len(lines), view)
+ }
+ for _, line := range lines {
+ if width := ansi.StringWidth(line); width > 20 {
+ t.Fatalf("line width = %d:\n%s", width, view)
+ }
+ if strings.HasPrefix(line, "│") && !strings.HasSuffix(line, "│") {
+ t.Fatalf("content crossed the right border: %q", line)
+ }
+ }
+}
+
+func TestScrollAndViewReuseRenderedMarkdown(t *testing.T) {
+ renders := 0
+ m := New(nil, "u")
+ m.renderMarkdown = func(source string, _ int) string {
+ renders++
+ return source
+ }
+ m.Resize(40, 10)
+ m.Open(board.Task{ID: "id", Title: "cached", Desc: strings.Repeat("line\n", 40), Status: board.StatusTodo})
+ initial := renders
+ if initial == 0 {
+ t.Fatal("open did not render the markdown cache")
+ }
+ for range 100 {
+ m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown})
+ m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+ }
+ for range 10 {
+ _ = m.View(40, 10)
+ }
+ if renders != initial {
+ t.Fatalf("scroll/view rerendered markdown: before %d after %d", initial, renders)
+ }
+ m.Resize(40, 20)
+ if renders != initial {
+ t.Fatalf("height-only resize rerendered markdown: before %d after %d", initial, renders)
+ }
+ m.Resize(30, 10)
+ if renders != initial+1 {
+ t.Fatalf("width change renders = %d, want %d", renders, initial+1)
+ }
+}
diff --git a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden
new file mode 100644
index 0000000..c088c0b
--- /dev/null
+++ b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden
@@ -0,0 +1,17 @@
+╭─────────────────────────────────────────────────────╮
+│ 🧭 Map it #7 │
+│ │
+│ status doing priority 1 blocked │
+│ due 2026-08-19 effort M │
+│ │
+│ labels [type::feature] │
+│ │
+│ links [github#86] │
+│ │
+│ Plan │
+│ │
+│ • first │
+│ https://example.com │
+│ │
+│ esc close ↑/↓ scroll 1/6 │
+╰─────────────────────────────────────────────────────╯
diff --git a/internal/tui/model.go b/internal/tui/model.go
index 61ba380..1feca89 100644
--- a/internal/tui/model.go
+++ b/internal/tui/model.go
@@ -9,6 +9,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/RandomCodeSpace/kb/internal/board"
+ "github.com/RandomCodeSpace/kb/internal/tui/carddetail"
)
const (
@@ -53,6 +54,7 @@ type Model struct {
user string
board board.Board
boardView boardViewState
+ detail carddetail.Model
width int
height int
loading bool
@@ -81,11 +83,13 @@ func newModel(
user string,
ctx context.Context,
) Model {
+ detailReader, _ := store.(carddetail.Reader)
return Model{
store: store,
watcher: watcher,
user: user,
board: board.Board{Title: "Board"},
+ detail: carddetail.New(detailReader, user),
width: defaultWidth,
height: defaultHeight,
loading: watcher == nil,
@@ -113,6 +117,25 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if m.stopped {
return m, nil
}
+ var detailCmd tea.Cmd
+ if m.detail.IsOpen() {
+ switch msg := message.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "esc":
+ m.detail.Close()
+ return m, nil
+ case "q", "ctrl+c":
+ // Preserve the root quit contract while the overlay is open.
+ default:
+ return m, m.detail.Update(message)
+ }
+ case boardCardClickedMsg, boardColumnClickedMsg:
+ return m, nil
+ default:
+ detailCmd = m.detail.Update(message)
+ }
+ }
switch msg := message.(type) {
case tea.KeyPressMsg:
switch msg.String() {
@@ -120,13 +143,24 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.stopped = true
m.reloadPending = false
return m, tea.Quit
+ case "enter":
+ if task, ok := m.selectedTask(); ok {
+ m.detail.Resize(m.width, m.height)
+ return m, m.detail.Open(task)
+ }
+ return m, nil
default:
if m.boardView.handleKey(msg.String(), m.board) == boardToggledCancelled {
return m, m.queueCancelledPreference()
}
}
case boardCardClickedMsg:
- m.boardView.focusTask(m.board, msg.taskID)
+ if m.boardView.focusTask(m.board, msg.taskID) {
+ if task, ok := m.selectedTask(); ok {
+ m.detail.Resize(m.width, m.height)
+ return m, m.detail.Open(task)
+ }
+ }
case boardColumnClickedMsg:
m.boardView.focusColumn(msg.status, m.board)
case cancelledPreferenceSavedMsg:
@@ -138,16 +172,17 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if msg.Height > 0 {
m.height = msg.Height
}
+ m.detail.Resize(m.width, m.height)
case boardLoadedMsg:
next := m.finishBoardLoad(msg)
- return m, next
+ return m, batchCommands(detailCmd, next)
case pollTickMsg:
return m, m.readDataVersion()
case dataVersionMsg:
next := m.observeDataVersion(msg)
return m, next
}
- return m, nil
+ return m, detailCmd
}
// observeDataVersion advances the watcher baseline and schedules exactly one
@@ -186,16 +221,32 @@ func (m *Model) observeDataVersion(msg dataVersionMsg) tea.Cmd {
func (m *Model) finishBoardLoad(msg boardLoadedMsg) tea.Cmd {
m.loading = false
m.loadErr = msg.err
+ var detailCmd tea.Cmd
if msg.err == nil {
previous := m.board
m.board = msg.board
m.boardView.adoptBoard(previous, m.board)
+ detailCmd = m.reconcileDetail()
}
if !m.reloadPending {
- return nil
+ return detailCmd
}
m.reloadPending = false
- return m.startBoardLoad()
+ return batchCommands(detailCmd, m.startBoardLoad())
+}
+
+func (m *Model) reconcileDetail() tea.Cmd {
+ if !m.detail.IsOpen() {
+ return nil
+ }
+ taskID := m.detail.TaskID()
+ for _, task := range m.board.Tasks {
+ if task.ID == taskID {
+ return m.detail.Refresh(task)
+ }
+ }
+ m.detail.Close()
+ return nil
}
// startBoardLoad starts a fallback or retry only when no load is active. The
@@ -225,10 +276,30 @@ func pollAfter(load tea.Cmd) tea.Cmd {
return tea.Batch(load, schedulePoll())
}
+func batchCommands(commands ...tea.Cmd) tea.Cmd {
+ filtered := make([]tea.Cmd, 0, len(commands))
+ for _, command := range commands {
+ if command != nil {
+ filtered = append(filtered, command)
+ }
+ }
+ if len(filtered) == 0 {
+ return nil
+ }
+ if len(filtered) == 1 {
+ return filtered[0]
+ }
+ return tea.Batch(filtered...)
+}
+
// View renders the responsive read-only board and wires view-derived mouse hit
// regions back into the update loop. Editing behavior arrives in later slices.
func (m Model) View() tea.View {
content, hits := m.renderBoard()
+ if m.detail.IsOpen() {
+ content = m.detail.Overlay(content, m.width, m.height)
+ hits = nil
+ }
view := tea.NewView(content)
view.AltScreen = true
view.MouseMode = tea.MouseModeCellMotion
diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go
index 9c50ec3..bcc1236 100644
--- a/internal/tui/model_test.go
+++ b/internal/tui/model_test.go
@@ -16,6 +16,7 @@ import (
"github.com/charmbracelet/x/exp/teatest/v2"
"github.com/RandomCodeSpace/kb/internal/board"
+ "github.com/RandomCodeSpace/kb/internal/store"
)
type stubBoardReader struct {
@@ -23,6 +24,40 @@ type stubBoardReader struct {
err error
}
+type stubDetailBoardReader struct{ stubBoardReader }
+
+func (stubDetailBoardReader) Comments(string, string) ([]store.Comment, error) {
+ return nil, nil
+}
+
+func (stubDetailBoardReader) TaskLinks(string, string) (store.TaskLinks, error) {
+ return store.TaskLinks{}, nil
+}
+
+func (stubDetailBoardReader) Tombstone(string, string) (store.Tombstone, bool, error) {
+ return store.Tombstone{}, false, nil
+}
+
+type mutableDetailReader struct {
+ board board.Board
+ commentLoads int
+}
+
+func (r *mutableDetailReader) Board(string) (board.Board, error) { return r.board, nil }
+
+func (r *mutableDetailReader) Comments(string, string) ([]store.Comment, error) {
+ r.commentLoads++
+ return []store.Comment{{ID: r.commentLoads, Author: "watcher", Body: fmt.Sprintf("enrichment %d", r.commentLoads)}}, nil
+}
+
+func (*mutableDetailReader) TaskLinks(string, string) (store.TaskLinks, error) {
+ return store.TaskLinks{}, nil
+}
+
+func (*mutableDetailReader) Tombstone(string, string) (store.Tombstone, bool, error) {
+ return store.Tombstone{}, false, nil
+}
+
func (s stubBoardReader) Board(string) (board.Board, error) { return s.board, s.err }
type stubVersionReader struct {
@@ -161,6 +196,117 @@ func TestModelLoadsRoutesAndRenders(t *testing.T) {
}
}
+func TestCardDetailOpensFromKeyboardAndClick(t *testing.T) {
+ tasks := []board.Task{
+ {ID: "first", Seq: 1, Title: "First card", Desc: "detail-only description", Status: board.StatusTodo},
+ {ID: "second", Seq: 2, Title: "Second card", Status: board.StatusTodo},
+ }
+ m := NewModel(stubDetailBoardReader{stubBoardReader{board: board.Board{Title: "Work", Tasks: tasks}}}, nil, "alice")
+ completeBoardLoad(t, &m, m.Init())
+
+ load := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ if !m.detail.IsOpen() || m.detail.TaskID() != "first" || load == nil {
+ t.Fatalf("enter detail state = open %v task %q command %v", m.detail.IsOpen(), m.detail.TaskID(), load)
+ }
+ updateTestModel(t, &m, load())
+ view := ansi.Strip(m.View().Content)
+ if !strings.Contains(view, "detail-only description") || !strings.Contains(view, "esc close") {
+ t.Fatalf("detail overlay missing content:\n%s", view)
+ }
+ columnBefore := m.boardView.column
+ rowBefore := m.boardView.rows[0]
+ updateTestModel(t, &m, tea.KeyPressMsg{Code: 'j'})
+ updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing})
+ if m.boardView.column != columnBefore || m.boardView.rows[0] != rowBefore {
+ t.Fatalf("overlay input leaked to board: %+v", m.boardView)
+ }
+ updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEsc})
+ if m.detail.IsOpen() {
+ t.Fatal("escape did not close detail")
+ }
+
+ updateTestModel(t, &m, boardCardClickedMsg{taskID: "second"})
+ if !m.detail.IsOpen() || m.detail.TaskID() != "second" || m.boardView.rows[0] != 1 {
+ t.Fatalf("click detail state = open %v task %q row %d", m.detail.IsOpen(), m.detail.TaskID(), m.boardView.rows[0])
+ }
+ if command := m.View().OnMouse(tea.MouseClickMsg{}); command != nil {
+ t.Fatal("board mouse handler remained active behind detail")
+ }
+ if quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'q'}); quit == nil || !m.stopped {
+ t.Fatal("q did not preserve root quit while detail was open")
+ }
+}
+
+func TestCardDetailOpenWithoutASelectedTaskIsNoop(t *testing.T) {
+ m := NewModel(stubBoardReader{board: board.Board{Title: "Empty"}}, nil, "u")
+ completeBoardLoad(t, &m, m.Init())
+ if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || m.detail.IsOpen() {
+ t.Fatalf("empty-board enter = command %v open %v", command, m.detail.IsOpen())
+ }
+ updateTestModel(t, &m, boardCardClickedMsg{taskID: "missing"})
+ if m.detail.IsOpen() {
+ t.Fatal("missing card click opened detail")
+ }
+}
+
+func TestBoardReloadReconcilesOpenDetailAndCoalescesEnrichment(t *testing.T) {
+ reader := &mutableDetailReader{board: board.Board{Title: "Work", Tasks: []board.Task{{
+ ID: "same", Title: "Old", Desc: "old description", Status: board.StatusTodo,
+ }}}}
+ m := NewModel(reader, nil, "u")
+ completeBoardLoad(t, &m, m.Init())
+ firstDetailLoad := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ updated := board.Task{
+ ID: "same", Title: "Updated", Desc: "updated description", Status: board.StatusDoing,
+ Checks: []board.Check{{Text: "fresh check", Done: true}},
+ }
+ latest := updated
+ latest.Title = "Latest"
+ latest.Desc = "latest description"
+ for _, snapshot := range []board.Board{
+ {Title: "Work", Tasks: []board.Task{updated}},
+ {Title: "Work", Tasks: []board.Task{latest}},
+ } {
+ if command := updateTestModel(t, &m, boardLoadedMsg{board: snapshot}); command != nil {
+ t.Fatal("board refresh overlapped an active detail enrichment load")
+ }
+ }
+ if view := ansi.Strip(m.View().Content); !strings.Contains(view, "Latest") || !strings.Contains(view, "latest description") || !strings.Contains(view, "fresh check") {
+ t.Fatalf("board refresh did not replace the open task snapshot:\n%s", view)
+ }
+
+ successor := updateTestModel(t, &m, firstDetailLoad())
+ if successor == nil {
+ t.Fatal("root discarded the coalesced detail successor")
+ }
+ updateTestModel(t, &m, successor())
+ if reader.commentLoads != 2 || !strings.Contains(ansi.Strip(m.View().Content), "enrichment 2") {
+ t.Fatalf("coalesced enrichment = loads %d view:\n%s", reader.commentLoads, ansi.Strip(m.View().Content))
+ }
+
+ idleUpdate := latest
+ idleUpdate.Desc = "idle refresh"
+ refresh := updateTestModel(t, &m, boardLoadedMsg{board: board.Board{Title: "Work", Tasks: []board.Task{idleUpdate}}})
+ if refresh == nil {
+ t.Fatal("idle board refresh did not reload detail enrichment")
+ }
+ updateTestModel(t, &m, refresh())
+ if reader.commentLoads != 3 || !strings.Contains(ansi.Strip(m.View().Content), "idle refresh") {
+ t.Fatalf("idle detail refresh = loads %d view:\n%s", reader.commentLoads, ansi.Strip(m.View().Content))
+ }
+
+ wantErr := errors.New("external read failed")
+ updateTestModel(t, &m, boardLoadedMsg{err: wantErr})
+ if !m.detail.IsOpen() || !strings.Contains(ansi.Strip(m.View().Content), "idle refresh") {
+ t.Fatal("failed board reload discarded the last-good detail")
+ }
+ updateTestModel(t, &m, boardLoadedMsg{board: board.Board{Title: "Work"}})
+ if m.detail.IsOpen() {
+ t.Fatal("deleted task left a ghost detail snapshot")
+ }
+}
+
func TestRenderFitsNarrowTerminal(t *testing.T) {
m := NewModel(stubBoardReader{}, nil, strings.Repeat("owner", 20))
m.loading = false