feat(nadezhda): M5 bubbletea TUI browser (cybercore) + open-in-browser

Interactive terminal UI over the ranked story clusters. Rave Maximal
cybercore theming: cyan-to-magenta spectrum score bars, severity-reactive
rows, hot KEV chips, cool-ramp outlet dots, and a scrollable detail
dossier with per-outlet links and full CVE cards (CVSS meter, KEV /
ransomware, EPSS, CWE, vector, description). Reuses store.DigestClusters
and rank.Rank verbatim and preloads full store.CVE records for detail.

- internal/tui: model/update/view state machine with an injected Loader
  and opener, so Update is fully unit-testable without a terminal or DB
  (nav / clamp / open / back / quit / resize / empty-store / too-small).
- o opens the selected story in the default browser, keyboard-only,
  cross-platform, and refuses non-http urls.
- tui command replaces the M5 stub; supports --since.
- deps: charmbracelet bubbletea v1.3.10 + bubbles v1.0.0 + lipgloss v1.1.0.
This commit is contained in:
CarterPerez-dev 2026-07-06 01:43:56 -04:00
parent 8713712bf9
commit 26651547e1
14 changed files with 1902 additions and 1 deletions

View File

@ -21,7 +21,6 @@ func init() {
short string
milestone string
}{
{"tui", "Browse aggregated news in an interactive terminal UI", "milestone M5"},
{"ideate", "Generate content angles from ranked clusters via an AI provider", "milestone M6"},
{"watch", "Run as a daemon, re-ingesting on an interval", "milestone M7"},
}

View File

@ -0,0 +1,75 @@
// ©AngelaMos | 2026
// tui.go
package main
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/CarterPerez-dev/nadezhda/internal/rank"
"github.com/CarterPerez-dev/nadezhda/internal/store"
"github.com/CarterPerez-dev/nadezhda/internal/tui"
)
var tuiSince string
var tuiCmd = &cobra.Command{
Use: "tui",
Short: "Browse aggregated news in an interactive terminal UI",
RunE: runTUI,
}
func init() {
tuiCmd.Flags().StringVar(&tuiSince, "since", "", "only clusters active within this window (e.g. 24h, 168h)")
rootCmd.AddCommand(tuiCmd)
}
func runTUI(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
now := time.Now()
var since int64
if tuiSince != "" {
d, err := time.ParseDuration(tuiSince)
if err != nil {
return fmt.Errorf("invalid --since %q: %w", tuiSince, err)
}
since = now.Add(-d).Unix()
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
loader := func() (tui.Data, error) {
clusters, err := st.DigestClusters(since)
if err != nil {
return tui.Data{}, err
}
scored := rank.Rank(clusters, cfg.Rank, cfg.Watchlist, now)
detail := make(map[string]store.CVE)
for _, s := range scored {
for _, v := range s.Cluster.CVEs {
if _, ok := detail[v.ID]; ok {
continue
}
full, err := st.GetCVE(v.ID)
if err != nil {
continue
}
detail[v.ID] = full
}
}
return tui.Data{Scored: scored, CVEDetail: detail}, nil
}
return tui.Run(loader)
}

View File

@ -4,6 +4,9 @@ go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/mmcdole/gofeed v1.3.0
github.com/spf13/cobra v1.10.2
github.com/temoto/robotstxt v1.1.2
@ -15,17 +18,34 @@ require (
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect

View File

@ -2,12 +2,36 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@ -20,8 +44,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
@ -31,12 +61,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@ -48,6 +86,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -56,6 +96,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -86,6 +128,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -0,0 +1,31 @@
// ©AngelaMos | 2026
// browser.go
package tui
import (
"fmt"
"net/url"
"os/exec"
"runtime"
)
func openURL(target string) error {
u, err := url.Parse(target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("refusing to open non-http url: %q", target)
}
name, args := openerCommand(target)
return exec.Command(name, args...).Start()
}
func openerCommand(target string) (string, []string) {
switch runtime.GOOS {
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", target}
case "darwin":
return "open", []string{target}
default:
return "xdg-open", []string{target}
}
}

View File

@ -0,0 +1,202 @@
// ©AngelaMos | 2026
// detail.go
package tui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/CarterPerez-dev/nadezhda/internal/rank"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
const (
cvssMaxScore = 10.0
nvdBaseURL = "https://nvd.nist.gov/vuln/detail/"
detailIndent = 3
metaDivider = " · "
)
func (m Model) renderDetailBody() string {
s := m.selected()
c := s.Cluster
w := m.bodyWidth()
t := m.theme
indent := strings.Repeat(" ", detailIndent)
var b strings.Builder
b.WriteString(t.HeadlineSel.Width(w).Render(headlineOf(c)))
b.WriteString("\n")
b.WriteString(m.detailMeta(s))
b.WriteString("\n\n")
b.WriteString(m.sectionHeader("OUTLETS", w))
b.WriteString("\n")
for _, a := range sortedArticles(c) {
b.WriteString(m.renderArticle(a, w, indent))
}
cves := m.sortedDetailCVEs(c)
if len(cves) > 0 {
b.WriteString(m.sectionHeader("VULNERABILITIES", w))
b.WriteString("\n")
for _, v := range cves {
b.WriteString(m.renderCVE(v, w, indent))
}
}
return strings.TrimRight(b.String(), "\n")
}
func (m Model) detailMeta(s rank.Scored) string {
t := m.theme
c := s.Cluster
parts := []string{
t.spectrumBar(s.Score, colScoreBarW) + " " + t.Meta.Bold(true).Render(fmt.Sprintf("%.2f", s.Score)),
t.Muted.Render(fmt.Sprintf("%d outlets", outletCount(c))),
}
if mx := clusterMaxCVSS(c); mx != nil {
b := cvssBand(mx)
parts = append(parts, t.Muted.Render("CVSS ")+t.bandFG(b).Bold(true).Render(cvssString(mx)))
}
if me := clusterMaxEPSS(c); me != nil {
b := epssBand(me)
parts = append(parts, t.Muted.Render("EPSS ")+t.bandFG(b).Bold(true).Render(epssString(me)))
}
parts = append(parts,
t.Muted.Render("first "+relativeAge(c.FirstSeen, m.now)),
t.Muted.Render("last "+relativeAge(c.LastSeen, m.now)),
)
return strings.Join(parts, t.Dim.Render(metaDivider))
}
func (m Model) sectionHeader(title string, w int) string {
t := m.theme
head := t.fg(colorViolet).Bold(true).Render(glyphSelected + " " + title + " ")
n := w - lipgloss.Width(head)
if n < 0 {
n = 0
}
return head + t.Dim.Render(strings.Repeat(ruleBody, n))
}
func (m Model) renderArticle(a store.DigestArticle, w int, indent string) string {
t := m.theme
head := t.fg(colorCyan).Bold(true).Render(a.SourceName) +
t.Dim.Render(metaDivider) + t.Muted.Render(relativeAge(a.PublishedAt, m.now))
title := m.wrapIndent(a.Title, w, t.Text)
url := indent + t.Link.Render(truncate(a.CanonicalURL, w-detailIndent))
return head + "\n" + title + "\n" + url + "\n\n"
}
func (m Model) renderCVE(v store.CVE, w int, indent string) string {
t := m.theme
bnd := cvssBand(v.CVSSScore)
var b strings.Builder
id := t.fg(colorCyan).Bold(true).Render(v.ID)
if v.IsKEV {
id += " " + t.chip("KEV", colorMagenta)
}
if v.KEVRansomware {
id += " " + t.chip("RANSOMWARE", colorMagenta)
}
id += " " + t.bandFG(bnd).Bold(true).Render(bandLabel(bnd))
b.WriteString(id + "\n")
if v.CVSSScore != nil {
line := indent + t.Label.Render("CVSS ") +
t.bandBar(bnd, *v.CVSSScore/cvssMaxScore, colScoreBarW) + " " +
t.bandFG(bnd).Bold(true).Render(cvssString(v.CVSSScore))
if v.CVSSSeverity != "" {
line += " " + t.Muted.Render(v.CVSSSeverity)
}
if v.CVSSVersion != "" {
line += t.Dim.Render(" (v" + v.CVSSVersion + ")")
}
b.WriteString(line + "\n")
}
if v.EPSS != nil {
eb := epssBand(v.EPSS)
line := indent + t.Label.Render("EPSS ") + t.bandFG(eb).Bold(true).Render(epssString(v.EPSS))
if v.EPSSPercentile != nil {
line += t.Dim.Render(" (percentile ") + t.Muted.Render(epssString(v.EPSSPercentile)) + t.Dim.Render(")")
}
b.WriteString(line + "\n")
}
var meta []string
if v.CWE != "" {
meta = append(meta, t.Label.Render("CWE ")+t.Muted.Render(v.CWE))
}
if v.CVSSVector != "" {
meta = append(meta, t.Dim.Render(v.CVSSVector))
}
if len(meta) > 0 {
b.WriteString(indent + strings.Join(meta, t.Dim.Render(metaDivider)) + "\n")
}
if strings.TrimSpace(v.Description) != "" {
b.WriteString(m.wrapIndent(v.Description, w, t.Muted) + "\n")
}
b.WriteString(indent + t.Link.Render(nvdBaseURL+v.ID) + "\n\n")
return b.String()
}
func (m Model) wrapIndent(s string, w int, style lipgloss.Style) string {
width := w - detailIndent
if width < 1 {
width = 1
}
wrapped := style.Width(width).Render(strings.TrimSpace(s))
pad := strings.Repeat(" ", detailIndent)
lines := strings.Split(wrapped, "\n")
for i := range lines {
lines[i] = pad + lines[i]
}
return strings.Join(lines, "\n")
}
func sortedArticles(c store.DigestCluster) []store.DigestArticle {
out := make([]store.DigestArticle, len(c.Articles))
copy(out, c.Articles)
sort.SliceStable(out, func(i, j int) bool {
if out[i].PublishedAt != out[j].PublishedAt {
return out[i].PublishedAt > out[j].PublishedAt
}
return out[i].ID < out[j].ID
})
return out
}
func (m Model) sortedDetailCVEs(c store.DigestCluster) []store.CVE {
out := make([]store.CVE, 0, len(c.CVEs))
for _, dc := range c.CVEs {
if full, ok := m.cveDetail[dc.ID]; ok {
out = append(out, full)
continue
}
out = append(out, store.CVE{ID: dc.ID, CVSSScore: dc.CVSSScore, EPSS: dc.EPSS, IsKEV: dc.IsKEV})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].IsKEV != out[j].IsKEV {
return out[i].IsKEV
}
if ci, cj := scoreOf(out[i].CVSSScore), scoreOf(out[j].CVSSScore); ci != cj {
return ci > cj
}
return out[i].ID < out[j].ID
})
return out
}
func scoreOf(p *float64) float64 {
if p == nil {
return -1
}
return *p
}

View File

@ -0,0 +1,197 @@
// ©AngelaMos | 2026
// format.go
package tui
import (
"fmt"
"strings"
"time"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
type band int
const (
bandNone band = iota
bandLow
bandMedium
bandHigh
bandCritical
)
const (
cvssCritical = 9.0
cvssHigh = 7.0
cvssMedium = 4.0
epssHot = 0.5
epssWarm = 0.1
secsPerMinute = 60
secsPerHour = 3600
secsPerDay = 86400
ellipsis = "…"
emptyMarker = "───"
naMarker = "—"
)
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func cvssBand(score *float64) band {
if score == nil {
return bandNone
}
switch v := *score; {
case v >= cvssCritical:
return bandCritical
case v >= cvssHigh:
return bandHigh
case v >= cvssMedium:
return bandMedium
case v > 0:
return bandLow
default:
return bandNone
}
}
func bandLabel(b band) string {
switch b {
case bandCritical:
return "CRITICAL"
case bandHigh:
return "HIGH"
case bandMedium:
return "MEDIUM"
case bandLow:
return "LOW"
default:
return naMarker
}
}
func clusterHasKEV(c store.DigestCluster) bool {
for _, v := range c.CVEs {
if v.IsKEV {
return true
}
}
return false
}
func clusterMaxCVSS(c store.DigestCluster) *float64 {
var best *float64
for _, v := range c.CVEs {
if v.CVSSScore == nil {
continue
}
if best == nil || *v.CVSSScore > *best {
best = v.CVSSScore
}
}
return best
}
func clusterMaxEPSS(c store.DigestCluster) *float64 {
var best *float64
for _, v := range c.CVEs {
if v.EPSS == nil {
continue
}
if best == nil || *v.EPSS > *best {
best = v.EPSS
}
}
return best
}
func clusterBand(c store.DigestCluster) band {
if clusterHasKEV(c) {
return bandCritical
}
return cvssBand(clusterMaxCVSS(c))
}
func epssBand(p *float64) band {
if p == nil {
return bandNone
}
switch v := *p; {
case v >= epssHot:
return bandCritical
case v >= epssWarm:
return bandHigh
case v > 0:
return bandMedium
default:
return bandNone
}
}
func cvssString(score *float64) string {
if score == nil {
return naMarker
}
return fmt.Sprintf("%.1f", *score)
}
func epssString(p *float64) string {
if p == nil {
return naMarker
}
return fmt.Sprintf("%.1f%%", clamp01(*p)*100)
}
func relativeAge(unix int64, now time.Time) string {
if unix <= 0 {
return naMarker
}
secs := now.Unix() - unix
if secs < 0 {
secs = 0
}
switch {
case secs < secsPerMinute:
return "just now"
case secs < secsPerHour:
return fmt.Sprintf("%dm ago", secs/secsPerMinute)
case secs < secsPerDay:
return fmt.Sprintf("%dh ago", secs/secsPerHour)
default:
return fmt.Sprintf("%dd ago", secs/secsPerDay)
}
}
func truncate(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if max <= 0 {
return ""
}
if len(r) <= max {
return s
}
if max == 1 {
return ellipsis
}
return string(r[:max-1]) + ellipsis
}
func padRight(s string, width int) string {
w := len([]rune(s))
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}

View File

@ -0,0 +1,173 @@
// ©AngelaMos | 2026
// format_test.go
package tui
import (
"testing"
"time"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
func ptr(v float64) *float64 { return &v }
func TestCVSSBand(t *testing.T) {
cases := []struct {
in *float64
want band
}{
{nil, bandNone},
{ptr(0), bandNone},
{ptr(3.9), bandLow},
{ptr(4.0), bandMedium},
{ptr(6.9), bandMedium},
{ptr(7.0), bandHigh},
{ptr(8.9), bandHigh},
{ptr(9.0), bandCritical},
{ptr(10.0), bandCritical},
}
for _, c := range cases {
if got := cvssBand(c.in); got != c.want {
t.Errorf("cvssBand(%v) = %v, want %v", c.in, got, c.want)
}
}
}
func TestEPSSBand(t *testing.T) {
cases := []struct {
in *float64
want band
}{
{nil, bandNone},
{ptr(0), bandNone},
{ptr(0.05), bandMedium},
{ptr(0.1), bandHigh},
{ptr(0.49), bandHigh},
{ptr(0.5), bandCritical},
{ptr(0.97), bandCritical},
}
for _, c := range cases {
if got := epssBand(c.in); got != c.want {
t.Errorf("epssBand(%v) = %v, want %v", c.in, got, c.want)
}
}
}
func TestBandLabel(t *testing.T) {
cases := map[band]string{
bandCritical: "CRITICAL",
bandHigh: "HIGH",
bandMedium: "MEDIUM",
bandLow: "LOW",
bandNone: naMarker,
}
for b, want := range cases {
if got := bandLabel(b); got != want {
t.Errorf("bandLabel(%v) = %q, want %q", b, got, want)
}
}
}
func TestCVSSString(t *testing.T) {
if got := cvssString(nil); got != naMarker {
t.Errorf("cvssString(nil) = %q, want %q", got, naMarker)
}
if got := cvssString(ptr(9.8)); got != "9.8" {
t.Errorf("cvssString(9.8) = %q, want 9.8", got)
}
if got := cvssString(ptr(10)); got != "10.0" {
t.Errorf("cvssString(10) = %q, want 10.0", got)
}
}
func TestEPSSString(t *testing.T) {
if got := epssString(nil); got != naMarker {
t.Errorf("epssString(nil) = %q, want %q", got, naMarker)
}
if got := epssString(ptr(0.5)); got != "50.0%" {
t.Errorf("epssString(0.5) = %q, want 50.0%%", got)
}
if got := epssString(ptr(0.001)); got != "0.1%" {
t.Errorf("epssString(0.001) = %q, want 0.1%%", got)
}
}
func TestRelativeAge(t *testing.T) {
now := time.Unix(1_000_000, 0)
cases := []struct {
unix int64
want string
}{
{0, naMarker},
{now.Unix() + 100, "just now"},
{now.Unix() - 30, "just now"},
{now.Unix() - 120, "2m ago"},
{now.Unix() - 7200, "2h ago"},
{now.Unix() - 172800, "2d ago"},
}
for _, c := range cases {
if got := relativeAge(c.unix, now); got != c.want {
t.Errorf("relativeAge(%d) = %q, want %q", c.unix, got, c.want)
}
}
}
func TestTruncate(t *testing.T) {
cases := []struct {
in string
max int
want string
}{
{"hello world", 20, "hello world"},
{"hello world", 5, "hell" + ellipsis},
{" multi space ", 20, "multi space"},
{"abc", 1, ellipsis},
{"abc", 0, ""},
}
for _, c := range cases {
if got := truncate(c.in, c.max); got != c.want {
t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
}
}
}
func TestClusterSignals(t *testing.T) {
c := store.DigestCluster{
CVEs: []store.DigestCVE{
{ID: "CVE-A", CVSSScore: ptr(7.5), EPSS: ptr(0.2)},
{ID: "CVE-B", CVSSScore: ptr(9.8), EPSS: ptr(0.9), IsKEV: true},
{ID: "CVE-C"},
},
}
if !clusterHasKEV(c) {
t.Error("clusterHasKEV = false, want true")
}
if got := clusterMaxCVSS(c); got == nil || *got != 9.8 {
t.Errorf("clusterMaxCVSS = %v, want 9.8", got)
}
if got := clusterMaxEPSS(c); got == nil || *got != 0.9 {
t.Errorf("clusterMaxEPSS = %v, want 0.9", got)
}
if got := clusterBand(c); got != bandCritical {
t.Errorf("clusterBand = %v, want bandCritical (KEV)", got)
}
noKev := store.DigestCluster{CVEs: []store.DigestCVE{{ID: "CVE-D", CVSSScore: ptr(7.5)}}}
if got := clusterBand(noKev); got != bandHigh {
t.Errorf("clusterBand(no kev, 7.5) = %v, want bandHigh", got)
}
empty := store.DigestCluster{}
if got := clusterBand(empty); got != bandNone {
t.Errorf("clusterBand(empty) = %v, want bandNone", got)
}
}
func TestOutletColor(t *testing.T) {
cases := map[int]string{1: colorDim, 2: colorBlue, 3: colorCyan, 4: colorViolet, 9: colorViolet}
for n, want := range cases {
if got := outletColor(n); got != want {
t.Errorf("outletColor(%d) = %q, want %q", n, got, want)
}
}
}

View File

@ -0,0 +1,54 @@
// ©AngelaMos | 2026
// keys.go
package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Top key.Binding
Bottom key.Binding
Open key.Binding
Browser key.Binding
Back key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Top: key.NewBinding(
key.WithKeys("g", "home"),
key.WithHelp("g", "top"),
),
Bottom: key.NewBinding(
key.WithKeys("G", "end"),
key.WithHelp("G", "bottom"),
),
Open: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("⏎", "detail"),
),
Browser: key.NewBinding(
key.WithKeys("o"),
key.WithHelp("o", "open"),
),
Back: key.NewBinding(
key.WithKeys("esc", "backspace"),
key.WithHelp("esc", "back"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
}

View File

@ -0,0 +1,222 @@
// ©AngelaMos | 2026
// list.go
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/CarterPerez-dev/nadezhda/internal/rank"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
const (
colSelBarW = 1
colRankW = 2
colScoreBarW = 10
colScoreNumW = 4
colOutletW = 6
colCVEW = 5
colGaps = 6
minHeadlineW = 12
glyphSelected = "◆"
glyphHot = "▐"
glyphOutlet = "◉"
)
func (m Model) listView() string {
header := m.listChrome()
footer := m.listFooter()
capacity := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
if capacity < 1 {
capacity = 1
}
body := m.listBody(capacity)
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
}
func (m Model) listChrome() string {
right := m.theme.Meta.Render(fmt.Sprintf("%d stories", len(m.scored)))
return lipgloss.JoinVertical(lipgloss.Left,
m.spread(m.wordmark(), right),
m.theme.rule(m.width),
m.listColHeader(),
)
}
func (m Model) listColHeader() string {
t := m.theme.ColHead
left := strings.Join([]string{
" ",
fmt.Sprintf("%*s", colRankW, "#"),
padRight("STORY", m.headlineWidth()),
padRight("SCORE", colScoreBarW),
fmt.Sprintf("%*s", colScoreNumW, ""),
padRight("OUT", colOutletW),
}, " ")
return m.spread(t.Render(left), t.Render("CVE"))
}
func (m Model) listFooter() string {
keys := m.keyHints(m.keys.Up, m.keys.Down, m.keys.Open, m.keys.Browser, m.keys.Quit)
right := m.severityLegend()
if s := m.statusText(); s != "" {
right = s
}
return lipgloss.JoinVertical(lipgloss.Left,
m.theme.rule(m.width),
m.spread(keys, right),
)
}
func (m Model) listBody(capacity int) string {
if len(m.scored) == 0 {
return m.emptyBody(capacity)
}
first, last := windowRange(m.cursor, capacity, len(m.scored))
lines := make([]string, 0, capacity)
for i := first; i < last; i++ {
lines = append(lines, m.renderRow(i, m.scored[i], i == m.cursor))
}
for len(lines) < capacity {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func (m Model) emptyBody(capacity int) string {
t := m.theme
msg := t.Muted.Render("no stories in the store yet — run ") +
t.KeyGlyph.Render("nadezhda scrape") +
t.Muted.Render(" then ") +
t.KeyGlyph.Render("nadezhda enrich")
return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) renderRow(i int, s rank.Scored, selected bool) string {
t := m.theme
c := s.Cluster
b := clusterBand(c)
kev := clusterHasKEV(c)
barStyle := t.Dim
barGlyph := " "
switch {
case selected:
barGlyph, barStyle = glyphSelected, t.SelBar
case kev || b == bandCritical:
barGlyph, barStyle = glyphHot, t.bandFG(bandCritical)
case b != bandNone:
barGlyph, barStyle = glyphHot, t.bandFG(b)
}
rankStyle := t.Rank
headStyle := t.Headline
if selected {
rankStyle, headStyle = t.RankSel, t.HeadlineSel
}
hlW := m.headlineWidth()
left := strings.Join([]string{
barStyle.Render(barGlyph),
rankStyle.Render(fmt.Sprintf("%*d", colRankW, i+1)),
headStyle.Render(padRight(truncate(headlineOf(c), hlW), hlW)),
t.spectrumBar(s.Score, colScoreBarW),
t.Meta.Render(fmt.Sprintf("%*s", colScoreNumW, fmt.Sprintf("%.2f", s.Score))),
m.renderOutlets(c),
}, " ")
return m.spread(left, m.renderCVEChip(c, b, kev))
}
func windowRange(cursor, capacity, total int) (int, int) {
if capacity < 1 {
capacity = 1
}
first := 0
if cursor >= capacity {
first = cursor - capacity + 1
}
last := first + capacity
if last > total {
last = total
}
return first, last
}
func (m Model) renderOutlets(c store.DigestCluster) string {
n := outletCount(c)
dots := n
if dots > colOutletW {
dots = colOutletW
}
styled := m.theme.fg(outletColor(n)).Render(strings.Repeat(glyphOutlet, dots))
if pad := colOutletW - dots; pad > 0 {
styled += strings.Repeat(" ", pad)
}
return styled
}
func (m Model) renderCVEChip(c store.DigestCluster, b band, kev bool) string {
t := m.theme
if kev {
return t.chip("KEV", colorMagenta)
}
if max := clusterMaxCVSS(c); max != nil {
return t.bandFG(b).Bold(true).Render(cvssString(max))
}
if len(c.CVEs) > 0 {
return t.Dim.Render("cve")
}
return t.Dim.Render(emptyMarker)
}
func (m Model) headlineWidth() int {
fixed := colSelBarW + colRankW + colScoreBarW + colScoreNumW + colOutletW + colCVEW + colGaps
if w := m.width - fixed; w > minHeadlineW {
return w
}
return minHeadlineW
}
func headlineArticle(c store.DigestCluster) store.DigestArticle {
var best store.DigestArticle
for i, a := range c.Articles {
if i == 0 || a.PublishedAt > best.PublishedAt ||
(a.PublishedAt == best.PublishedAt && a.ID < best.ID) {
best = a
}
}
return best
}
func headlineOf(c store.DigestCluster) string {
if a := headlineArticle(c); strings.TrimSpace(a.Title) != "" {
return a.Title
}
return "(untitled cluster)"
}
func outletCount(c store.DigestCluster) int {
seen := make(map[string]struct{}, len(c.Articles))
for _, a := range c.Articles {
seen[a.SourceName] = struct{}{}
}
return len(seen)
}
func outletColor(n int) string {
switch {
case n >= 4:
return colorViolet
case n == 3:
return colorCyan
case n == 2:
return colorBlue
default:
return colorDim
}
}

View File

@ -0,0 +1,253 @@
// ©AngelaMos | 2026
// model.go
package tui
import (
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/CarterPerez-dev/nadezhda/internal/rank"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
type viewState int
const (
stateLoading viewState = iota
stateList
stateDetail
stateError
)
const (
defaultWidth = 100
defaultHeight = 32
detailChrome = 4
spinnerFPS = time.Second / 10
)
var raveSpinner = spinner.Spinner{
Frames: []string{"◇", "◈", "◆", "◈", "◇", "·"},
FPS: spinnerFPS,
}
type Data struct {
Scored []rank.Scored
CVEDetail map[string]store.CVE
}
type Loader func() (Data, error)
type dataMsg struct{ data Data }
type errMsg struct{ err error }
type openedMsg struct {
url string
err error
}
type Model struct {
state viewState
loader Loader
now time.Time
theme Theme
keys keyMap
spinner spinner.Model
viewport viewport.Model
width int
height int
scored []rank.Scored
cveDetail map[string]store.CVE
cursor int
err error
opener func(string) error
status string
statusErr bool
}
func New(loader Loader, now time.Time) Model {
th := NewTheme()
sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner))
m := Model{
state: stateLoading,
loader: loader,
now: now,
theme: th,
keys: defaultKeyMap(),
spinner: sp,
viewport: viewport.New(defaultWidth, defaultHeight-detailChrome),
width: defaultWidth,
height: defaultHeight,
cveDetail: map[string]store.CVE{},
opener: openURL,
}
return m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, m.load())
}
func (m Model) load() tea.Cmd {
loader := m.loader
return func() tea.Msg {
data, err := loader()
if err != nil {
return errMsg{err}
}
return dataMsg{data}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.applySize(msg.Width, msg.Height)
return m, nil
case dataMsg:
m.scored = msg.data.Scored
m.cveDetail = msg.data.CVEDetail
m.state = stateList
return m, nil
case errMsg:
m.state = stateError
m.err = msg.err
return m, nil
case openedMsg:
if msg.err != nil {
m.status, m.statusErr = "open failed: "+msg.err.Error(), true
} else {
m.status, m.statusErr = "opened in browser", false
}
return m, nil
case spinner.TickMsg:
if m.state != stateLoading {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
return m.handleKey(msg)
}
if m.state == stateDetail {
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = ""
if key.Matches(msg, m.keys.Quit) {
return m, tea.Quit
}
if key.Matches(msg, m.keys.Browser) && (m.state == stateList || m.state == stateDetail) {
return m, m.openSelected()
}
switch m.state {
case stateList:
return m.handleListKey(msg)
case stateDetail:
return m.handleDetailKey(msg)
default:
return m, nil
}
}
func (m Model) openSelected() tea.Cmd {
target := headlineArticle(m.selected().Cluster).CanonicalURL
if target == "" {
return nil
}
open := m.opener
return func() tea.Msg {
return openedMsg{url: target, err: open(target)}
}
}
func (m Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(msg, m.keys.Down):
if m.cursor < len(m.scored)-1 {
m.cursor++
}
case key.Matches(msg, m.keys.Top):
m.cursor = 0
case key.Matches(msg, m.keys.Bottom):
if len(m.scored) > 0 {
m.cursor = len(m.scored) - 1
}
case key.Matches(msg, m.keys.Open):
if len(m.scored) > 0 {
m.state = stateDetail
m.viewport.SetContent(m.renderDetailBody())
m.viewport.GotoTop()
}
}
return m, nil
}
func (m Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if key.Matches(msg, m.keys.Back) {
m.state = stateList
return m, nil
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m *Model) applySize(w, h int) {
m.width = w
m.height = h
m.viewport.Width = m.bodyWidth()
m.viewport.Height = m.detailBodyHeight()
if m.state == stateDetail {
m.viewport.SetContent(m.renderDetailBody())
}
}
func (m Model) bodyWidth() int {
if m.width > 1 {
return m.width
}
return 1
}
func (m Model) detailBodyHeight() int {
if h := m.height - detailChrome; h > 1 {
return h
}
return 1
}
func (m Model) selected() rank.Scored {
if m.cursor < 0 || m.cursor >= len(m.scored) {
return rank.Scored{}
}
return m.scored[m.cursor]
}
func Run(loader Loader) error {
m := New(loader, time.Now())
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
var _ tea.Model = Model{}

View File

@ -0,0 +1,322 @@
// ©AngelaMos | 2026
// model_test.go
package tui
import (
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/CarterPerez-dev/nadezhda/internal/rank"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
func testNow() time.Time { return time.Unix(1_720_000_000, 0) }
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
func sampleData() Data {
now := testNow().Unix()
c1 := store.DigestCluster{
ClusterID: 1,
Key: "log4shell",
Size: 2,
FirstSeen: now - 3600,
LastSeen: now - 600,
Articles: []store.DigestArticle{
{ID: 1, SourceName: "krebs", SourceWeight: 1.0, Title: "Log4Shell RCE exploited in the wild", CanonicalURL: "https://krebsonsecurity.com/log4shell", PublishedAt: now - 600},
{ID: 2, SourceName: "theregister", SourceWeight: 0.9, Title: "Log4j flaw under active attack", CanonicalURL: "https://theregister.com/log4j", PublishedAt: now - 3600},
},
CVEs: []store.DigestCVE{
{ID: "CVE-2021-44228", CVSSScore: ptr(10.0), EPSS: ptr(0.97), IsKEV: true},
},
}
c2 := store.DigestCluster{
ClusterID: 2,
Key: "policy",
Size: 1,
FirstSeen: now - 7200,
LastSeen: now - 7200,
Articles: []store.DigestArticle{
{ID: 3, SourceName: "darkreading", SourceWeight: 0.7, Title: "New disclosure guidelines published", CanonicalURL: "https://darkreading.com/policy", PublishedAt: now - 7200},
},
}
scored := []rank.Scored{{Cluster: c1, Score: 0.94}, {Cluster: c2, Score: 0.42}}
detail := map[string]store.CVE{
"CVE-2021-44228": {
ID: "CVE-2021-44228",
Description: "Apache Log4j2 JNDI features do not protect against attacker controlled LDAP endpoints.",
CVSSScore: ptr(10.0),
CVSSVersion: "3.1",
CVSSSeverity: "CRITICAL",
CVSSVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
CWE: "CWE-502",
IsKEV: true,
EPSS: ptr(0.97),
},
}
return Data{Scored: scored, CVEDetail: detail}
}
func toModel(t *testing.T, tm tea.Model) Model {
t.Helper()
m, ok := tm.(Model)
if !ok {
t.Fatalf("Update returned %T, want tui.Model", tm)
}
return m
}
func step(t *testing.T, m Model, msg tea.Msg) Model {
t.Helper()
tm, _ := m.Update(msg)
return toModel(t, tm)
}
func loadedModel(t *testing.T) Model {
t.Helper()
return step(t, New(nil, testNow()), dataMsg{sampleData()})
}
func TestInitialStateIsLoading(t *testing.T) {
if m := New(nil, testNow()); m.state != stateLoading {
t.Fatalf("initial state = %v, want stateLoading", m.state)
}
}
func TestDataMsgTransitionsToList(t *testing.T) {
m := loadedModel(t)
if m.state != stateList {
t.Fatalf("state = %v, want stateList", m.state)
}
if len(m.scored) != 2 {
t.Fatalf("len(scored) = %d, want 2", len(m.scored))
}
}
func TestErrMsgTransitionsToError(t *testing.T) {
m := step(t, New(nil, testNow()), errMsg{errors.New("wire down")})
if m.state != stateError {
t.Fatalf("state = %v, want stateError", m.state)
}
if m.err == nil {
t.Fatal("err = nil, want non-nil")
}
}
func TestListNavigationClamps(t *testing.T) {
m := loadedModel(t)
m = step(t, m, runeKey('j'))
if m.cursor != 1 {
t.Fatalf("after down, cursor = %d, want 1", m.cursor)
}
m = step(t, m, runeKey('j'))
if m.cursor != 1 {
t.Fatalf("after down past end, cursor = %d, want 1", m.cursor)
}
m = step(t, m, runeKey('k'))
if m.cursor != 0 {
t.Fatalf("after up, cursor = %d, want 0", m.cursor)
}
m = step(t, m, runeKey('k'))
if m.cursor != 0 {
t.Fatalf("after up past start, cursor = %d, want 0", m.cursor)
}
}
func TestListTopBottom(t *testing.T) {
m := loadedModel(t)
m = step(t, m, runeKey('G'))
if m.cursor != 1 {
t.Fatalf("after G, cursor = %d, want 1", m.cursor)
}
m = step(t, m, runeKey('g'))
if m.cursor != 0 {
t.Fatalf("after g, cursor = %d, want 0", m.cursor)
}
}
func TestOpenAndBack(t *testing.T) {
m := loadedModel(t)
m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter})
if m.state != stateDetail {
t.Fatalf("after enter, state = %v, want stateDetail", m.state)
}
m = step(t, m, tea.KeyMsg{Type: tea.KeyEsc})
if m.state != stateList {
t.Fatalf("after esc, state = %v, want stateList", m.state)
}
}
func TestQuitReturnsQuitCmd(t *testing.T) {
m := loadedModel(t)
_, cmd := m.Update(runeKey('q'))
if cmd == nil {
t.Fatal("quit returned nil cmd")
}
if reflect.TypeOf(cmd()) != reflect.TypeOf(tea.Quit()) {
t.Fatalf("quit cmd yielded %T, want tea.QuitMsg", cmd())
}
}
func TestWindowSizeSizesViewport(t *testing.T) {
m := loadedModel(t)
m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40})
if m.width != 120 || m.height != 40 {
t.Fatalf("size = %dx%d, want 120x40", m.width, m.height)
}
if m.viewport.Width != 120 {
t.Fatalf("viewport.Width = %d, want 120", m.viewport.Width)
}
if m.viewport.Height != 40-detailChrome {
t.Fatalf("viewport.Height = %d, want %d", m.viewport.Height, 40-detailChrome)
}
}
func TestSpinnerTickIgnoredOutsideLoading(t *testing.T) {
m := loadedModel(t)
m = step(t, m, spinner.TickMsg{})
if m.state != stateList {
t.Fatalf("spinner tick changed state to %v, want stateList", m.state)
}
}
func TestViewsRenderNonEmpty(t *testing.T) {
loading := New(nil, testNow())
if strings.TrimSpace(loading.View()) == "" {
t.Error("loading view is empty")
}
list := loadedModel(t)
lv := list.View()
if !strings.Contains(lv, "NADEZHDA") {
t.Error("list view missing brand")
}
if !strings.Contains(lv, "Log4Shell") {
t.Error("list view missing headline")
}
detail := step(t, list, tea.KeyMsg{Type: tea.KeyEnter})
dv := detail.View()
if !strings.Contains(dv, "CVE-2021-44228") {
t.Error("detail view missing CVE id")
}
if !strings.Contains(dv, "CRITICAL") {
t.Error("detail view missing severity label")
}
errv := step(t, New(nil, testNow()), errMsg{errors.New("boom")})
if strings.TrimSpace(errv.View()) == "" {
t.Error("error view is empty")
}
}
func TestEmptyStoreRendersHint(t *testing.T) {
m := step(t, New(nil, testNow()), dataMsg{Data{}})
if m.state != stateList {
t.Fatalf("state = %v, want stateList", m.state)
}
if !strings.Contains(m.View(), "scrape") {
t.Error("empty list view missing scrape hint")
}
}
func TestTooSmallTerminal(t *testing.T) {
m := step(t, loadedModel(t), tea.WindowSizeMsg{Width: 20, Height: 5})
if !strings.Contains(m.View(), "too small") {
t.Error("tiny terminal did not render the too-small notice")
}
}
func TestWindowRange(t *testing.T) {
cases := []struct {
cursor, capacity, total int
first, last int
}{
{0, 5, 10, 0, 5},
{4, 5, 10, 0, 5},
{5, 5, 10, 1, 6},
{9, 5, 10, 5, 10},
{0, 5, 3, 0, 3},
{2, 5, 3, 0, 3},
{0, 0, 10, 0, 1},
{7, 3, 10, 5, 8},
}
for _, c := range cases {
first, last := windowRange(c.cursor, c.capacity, c.total)
if first != c.first || last != c.last {
t.Errorf("windowRange(%d, %d, %d) = (%d, %d), want (%d, %d)",
c.cursor, c.capacity, c.total, first, last, c.first, c.last)
}
if c.cursor < c.total && (c.cursor < first || c.cursor >= last) {
t.Errorf("windowRange(%d, %d, %d) window [%d,%d) excludes cursor",
c.cursor, c.capacity, c.total, first, last)
}
}
}
func TestHeadlineFreshest(t *testing.T) {
fresh := store.DigestCluster{Articles: []store.DigestArticle{
{ID: 1, Title: "older", PublishedAt: 100},
{ID: 2, Title: "newest", PublishedAt: 300},
{ID: 3, Title: "mid", PublishedAt: 200},
}}
if got := headlineOf(fresh); got != "newest" {
t.Errorf("headlineOf(fresh) = %q, want newest", got)
}
tie := store.DigestCluster{Articles: []store.DigestArticle{
{ID: 5, Title: "high-id", PublishedAt: 300},
{ID: 2, Title: "low-id", PublishedAt: 300},
}}
if got := headlineOf(tie); got != "low-id" {
t.Errorf("headlineOf(tie) = %q, want low-id (lowest id on equal time)", got)
}
if got := headlineOf(store.DigestCluster{}); got != "(untitled cluster)" {
t.Errorf("headlineOf(empty) = %q, want (untitled cluster)", got)
}
}
func TestOpenInBrowser(t *testing.T) {
m := loadedModel(t)
var opened string
m.opener = func(u string) error { opened = u; return nil }
tm, cmd := m.Update(runeKey('o'))
m = toModel(t, tm)
if cmd == nil {
t.Fatal("pressing o produced no command")
}
msg := cmd()
if opened != "https://krebsonsecurity.com/log4shell" {
t.Fatalf("opened %q, want the selected cluster's headline url", opened)
}
om, ok := msg.(openedMsg)
if !ok {
t.Fatalf("open cmd returned %T, want openedMsg", msg)
}
m = step(t, m, om)
if m.status == "" || m.statusErr {
t.Errorf("after open: status=%q err=%v, want a success status", m.status, m.statusErr)
}
if !strings.Contains(m.View(), m.status) {
t.Error("open status not shown in the view")
}
m = step(t, m, runeKey('j'))
if m.status != "" {
t.Error("status should clear on the next keypress")
}
}
func TestOpenURLRejectsNonHTTP(t *testing.T) {
for _, bad := range []string{"file:///etc/passwd", "javascript:alert(1)", "", "ftp://x/y"} {
if err := openURL(bad); err == nil {
t.Errorf("openURL(%q) = nil, want refusal", bad)
}
}
}

View File

@ -0,0 +1,151 @@
// ©AngelaMos | 2026
// theme.go
package tui
import (
"math"
"strings"
"github.com/charmbracelet/lipgloss"
)
const (
colorVoid = "#0B0620"
colorText = "#E7E0FF"
colorMuted = "#9A8AD0"
colorDim = "#574B7D"
colorMagenta = "#FF2E97"
colorAmber = "#FF8A3D"
colorYellow = "#FFE04D"
colorCyan = "#00E5FF"
colorViolet = "#A65CFF"
colorBlue = "#2D6BFF"
)
const (
barFull = "█"
barEmpty = "░"
ruleLead = "▓▒░"
ruleBody = "─"
)
func bandColor(b band) string {
switch b {
case bandCritical:
return colorMagenta
case bandHigh:
return colorAmber
case bandMedium:
return colorYellow
case bandLow:
return colorCyan
default:
return colorDim
}
}
type Theme struct {
Brand lipgloss.Style
BrandMark lipgloss.Style
Meta lipgloss.Style
ColHead lipgloss.Style
Text lipgloss.Style
Muted lipgloss.Style
Dim lipgloss.Style
Rank lipgloss.Style
RankSel lipgloss.Style
Headline lipgloss.Style
HeadlineSel lipgloss.Style
SelBar lipgloss.Style
KeyGlyph lipgloss.Style
KeyDesc lipgloss.Style
Spinner lipgloss.Style
PanelTitle lipgloss.Style
Label lipgloss.Style
Link lipgloss.Style
}
func NewTheme() Theme {
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorText))
return Theme{
Brand: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true),
BrandMark: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
Meta: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)),
ColHead: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true),
Text: base,
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
Dim: lipgloss.NewStyle().Foreground(lipgloss.Color(colorDim)),
Rank: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
RankSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
Headline: base,
HeadlineSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)).Bold(true),
SelBar: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
KeyGlyph: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true),
KeyDesc: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
PanelTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true),
Label: lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)).Bold(true),
Link: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Underline(true),
}
}
func (t Theme) fg(color string) lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(color))
}
func (t Theme) bandFG(b band) lipgloss.Style {
return t.fg(bandColor(b))
}
func (t Theme) chip(text, color string) string {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(colorVoid)).
Background(lipgloss.Color(color)).
Bold(true).
Render(" " + text + " ")
}
func (t Theme) spectrumBar(frac float64, width int) string {
if width <= 0 {
return ""
}
filled := int(math.Round(clamp01(frac) * float64(width)))
stops := []string{colorCyan, colorBlue, colorViolet, colorMagenta}
var b strings.Builder
for i := 0; i < width; i++ {
if i < filled {
b.WriteString(t.fg(stops[i*len(stops)/width]).Render(barFull))
} else {
b.WriteString(t.Dim.Render(barEmpty))
}
}
return b.String()
}
func (t Theme) bandBar(b band, frac float64, width int) string {
if width <= 0 {
return ""
}
filled := int(math.Round(clamp01(frac) * float64(width)))
color := bandColor(b)
var sb strings.Builder
for i := 0; i < width; i++ {
if i < filled {
sb.WriteString(t.fg(color).Render(barFull))
} else {
sb.WriteString(t.Dim.Render(barEmpty))
}
}
return sb.String()
}
func (t Theme) rule(width int) string {
lead := len([]rune(ruleLead)) + 1
n := width - lead
if n < 0 {
return t.fg(colorViolet).Render(ruleLead)
}
return t.fg(colorViolet).Render(ruleLead) + " " + t.Dim.Render(strings.Repeat(ruleBody, n))
}

View File

@ -0,0 +1,159 @@
// ©AngelaMos | 2026
// view.go
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
)
const (
brandName = "NADEZHDA"
tagline = "THREAT.WIRE"
markOpen = "◤ "
markClose = " ◢"
markMid = "═◆═"
errWrapMax = 80
errWrapMin = 20
errWrapMargin = 6
minRenderWidth = 44
minRenderHeight = 10
)
func (m Model) View() string {
if m.width < minRenderWidth || m.height < minRenderHeight {
return m.tooSmallView()
}
switch m.state {
case stateLoading:
return m.loadingView()
case stateError:
return m.errorView()
case stateDetail:
return m.detailView()
default:
return m.listView()
}
}
func (m Model) tooSmallView() string {
w, h := m.width, m.height
if w < 1 {
w = 1
}
if h < 1 {
h = 1
}
msg := m.theme.Muted.Render(fmt.Sprintf("terminal too small — need at least %dx%d", minRenderWidth, minRenderHeight))
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) wordmark() string {
t := m.theme
return t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose) +
" " + t.fg(colorCyan).Render(markMid) + " " + t.fg(colorViolet).Render(tagline) + " " + t.fg(colorCyan).Render(markMid)
}
func (m Model) spread(left, right string) string {
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + right
}
func (m Model) keyHints(bindings ...key.Binding) string {
t := m.theme
parts := make([]string, 0, len(bindings))
for _, b := range bindings {
h := b.Help()
parts = append(parts, t.KeyGlyph.Render(h.Key)+" "+t.KeyDesc.Render(h.Desc))
}
return strings.Join(parts, t.Dim.Render(" · "))
}
func (m Model) severityLegend() string {
t := m.theme
item := func(color, label string) string {
return t.fg(color).Render("■") + " " + t.KeyDesc.Render(label)
}
return strings.Join([]string{
item(colorMagenta, "KEV/CRIT"),
item(colorAmber, "HIGH"),
item(colorYellow, "MED"),
item(colorCyan, "LOW"),
}, " ")
}
func (m Model) loadingView() string {
t := m.theme
brand := t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose)
tag := t.fg(colorViolet).Render(tagline)
line := m.spinner.View() + " " + t.Meta.Render("assembling threat wire") + " " + m.spinner.View()
block := lipgloss.JoinVertical(lipgloss.Center, brand, tag, "", line)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
}
func (m Model) errorView() string {
t := m.theme
title := t.fg(colorMagenta).Bold(true).Render("▚ WIRE FAULT ▞")
block := lipgloss.JoinVertical(lipgloss.Center,
title,
"",
m.errString(),
"",
t.KeyDesc.Render("press ")+t.KeyGlyph.Render("q")+t.KeyDesc.Render(" to quit"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
}
func (m Model) errString() string {
if m.err == nil {
return m.theme.Text.Render("unknown fault")
}
w := m.width - errWrapMargin
if w > errWrapMax {
w = errWrapMax
}
if w < errWrapMin {
w = errWrapMin
}
return m.theme.Text.Width(w).Align(lipgloss.Center).Render(m.err.Error())
}
func (m Model) detailView() string {
t := m.theme
scroll := fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100)
left := t.PanelTitle.Render(markOpen+"DOSSIER"+markClose) + " " +
t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored)))
head := m.spread(left, t.Meta.Render(scroll))
foot := m.spread(
m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Down, m.keys.Up, m.keys.Quit),
m.statusText(),
)
return lipgloss.JoinVertical(lipgloss.Left,
head,
t.rule(m.width),
m.viewport.View(),
t.rule(m.width),
foot,
)
}
func (m Model) statusText() string {
if m.status == "" {
return ""
}
color := colorCyan
if m.statusErr {
color = colorMagenta
}
return m.theme.fg(color).Render(m.status)
}