package tui import ( "fmt" "github.com/karol-broda/snitch/internal/collector" "github.com/karol-broda/snitch/internal/resolver" "strconv" "strings" "time" "github.com/mattn/go-runewidth" ) func (m model) renderMain() string { var b strings.Builder b.WriteString("\n") b.WriteString(m.renderTitle()) b.WriteString("\n") b.WriteString(m.renderFilters()) b.WriteString("\n\n") b.WriteString(m.renderTableHeader()) b.WriteString(m.renderSeparator()) b.WriteString(m.renderConnections()) b.WriteString("\n") b.WriteString(m.renderStatusLine()) return b.String() } func (m model) renderTitle() string { visible := m.visibleConnections() total := len(m.connections) left := m.theme.Styles.Header.Render("snitch") ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100) right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s %s", len(visible), total, SymbolRefresh, formatDuration(ago))) w := m.safeWidth() gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2 if gap < 0 { gap = 0 } return " " + left + strings.Repeat(" ", gap) + right } func (m model) renderFilters() string { var parts []string parts = append(parts, m.renderFilterLabel("t", "cp", m.showTCP)) parts = append(parts, m.renderFilterLabel("u", "dp", m.showUDP)) parts = append(parts, m.theme.Styles.Border.Render(BoxVertical)) parts = append(parts, m.renderFilterLabel("l", "isten", m.showListening)) parts = append(parts, m.renderFilterLabel("e", "stab", m.showEstablished)) parts = append(parts, m.renderFilterLabel("o", "ther", m.showOther)) left := " " + strings.Join(parts, " ") sortLabel := sortFieldLabel(m.sortField) sortDir := SymbolArrowUp if m.sortReverse { sortDir = SymbolArrowDown } var right string if m.searchActive { right = m.theme.Styles.Warning.Render(fmt.Sprintf("/%s▌", m.searchQuery)) } else if m.searchQuery != "" { right = m.theme.Styles.Normal.Render(fmt.Sprintf("filter: %s", m.searchQuery)) } else { right = m.theme.Styles.Normal.Render(fmt.Sprintf("sort: %s %s", sortLabel, sortDir)) } w := m.safeWidth() gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2 if gap < 0 { gap = 0 } return left + strings.Repeat(" ", gap) + right + " " } func (m model) renderTableHeader() string { cols := m.columnWidths() header := fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %s", cols.process, "PROCESS", cols.port, "PORT", cols.proto, "PROTO", cols.state, "STATE", cols.local, "LOCAL", "REMOTE") return m.theme.Styles.Header.Render(header) + "\n" } func (m model) renderFilterLabel(firstChar, rest string, active bool) string { baseStyle := m.theme.Styles.Normal if active { baseStyle = m.theme.Styles.Success } underlinedFirst := baseStyle.Underline(true).Render(firstChar) restPart := baseStyle.Render(rest) return underlinedFirst + restPart } func (m model) renderSeparator() string { w := m.width - 4 if w < 1 { w = 76 } line := " " + strings.Repeat(BoxHorizontal, w) return m.theme.Styles.Border.Render(line) + "\n" } func (m model) renderConnections() string { var b strings.Builder visible := m.visibleConnections() pageSize := m.pageSize() if len(visible) == 0 { b.WriteString(" " + m.theme.Styles.Normal.Render("no connections match filters") + "\n") for i := 1; i < pageSize; i++ { b.WriteString("\n") } return b.String() } start := m.scrollOffset(pageSize, len(visible)) for i := 0; i < pageSize; i++ { idx := start + i if idx >= len(visible) { b.WriteString("\n") continue } isSelected := idx == m.cursor b.WriteString(m.renderRow(visible[idx], isSelected)) } return b.String() } func (m model) renderRow(c collector.Connection, selected bool) string { cols := m.columnWidths() indicator := " " if selected { indicator = m.theme.Styles.Success.Render(SymbolSelected + " ") } else if m.isWatched(c.PID) { indicator = m.theme.Styles.Watched.Render(SymbolWatched + " ") } process := truncate(c.Process, cols.process) if process == "" { process = SymbolDash } port := truncate(m.resolvePort(c.Lport, c.Proto), cols.port) proto := c.Proto state := c.State if state == "" { state = SymbolDash } local := truncate(m.resolveAddr(c.Laddr), cols.local) if local == "*" || local == "" { local = "*" } remote := truncate(m.formatRemote(c.Raddr, c.Rport, c.Proto), cols.remote) // apply styling protoStyled := m.theme.Styles.GetProtoStyle(proto).Render(fmt.Sprintf("%-*s", cols.proto, proto)) stateStyled := m.theme.Styles.GetStateStyle(state).Render(fmt.Sprintf("%-*s", cols.state, truncate(state, cols.state))) row := fmt.Sprintf("%s%-*s %-*s %s %s %-*s %s", indicator, cols.process, process, cols.port, port, protoStyled, stateStyled, cols.local, local, remote) if selected { return m.theme.Styles.Selected.Render(row) + "\n" } return m.theme.Styles.Normal.Render(row) + "\n" } func (m model) renderStatusLine() string { // show status message if present if m.statusMessage != "" { return " " + m.theme.Styles.Warning.Render(m.statusMessage) } left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state n/N dns w watch K kill s sort / search x export ? help q quit") // show watched count if any if m.watchedCount() > 0 { watchedInfo := fmt.Sprintf(" watching: %d", m.watchedCount()) left += m.theme.Styles.Watched.Render(watchedInfo) } // show dns resolution status var resolveStatus string if m.resolveAddrs && m.resolvePorts { resolveStatus = "all" } else if m.resolveAddrs { resolveStatus = "addrs" } else if m.resolvePorts { resolveStatus = "ports" } else { resolveStatus = "off" } if resolveStatus != "addrs" { // addrs is the default, don't show left += m.theme.Styles.Normal.Render(fmt.Sprintf(" dns: %s", resolveStatus)) } return left } func (m model) renderError() string { return fmt.Sprintf("\n %s\n\n press q to quit\n", m.theme.Styles.Error.Render(fmt.Sprintf("error: %v", m.err))) } func (m model) renderHelp() string { help := ` navigation ────────── j/k ↑/↓ move cursor g/G jump to top/bottom ctrl+d/u half page down/up enter show connection details filters ─────── t toggle tcp u toggle udp l toggle listening e toggle established o toggle other states a reset all filters sorting ─────── s cycle sort field S reverse sort order display ─────── n toggle address resolution (dns) N toggle port resolution (service names) process management ────────────────── w watch/unwatch process (highlight & track) W clear all watched processes K kill process (with confirmation) other ───── / search x export to csv/tsv (enter filename) r refresh now q quit press ? or esc to close ` return m.theme.Styles.Normal.Render(help) } func (m model) renderDetail() string { if m.selected == nil { return "" } c := m.selected var b strings.Builder b.WriteString("\n") b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n") b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat(BoxHorizontal, 40)) + "\n\n") localAddr := m.resolveAddr(c.Laddr) localPort := m.resolvePort(c.Lport, c.Proto) remoteAddr := m.resolveAddr(c.Raddr) remotePort := m.resolvePort(c.Rport, c.Proto) fields := []struct { label string value string }{ {"process", c.Process}, {"cmdline", c.Cmdline}, {"cwd", c.Cwd}, {"pid", fmt.Sprintf("%d", c.PID)}, {"user", c.User}, {"protocol", c.Proto}, {"state", c.State}, {"local", fmt.Sprintf("%s:%s", localAddr, localPort)}, {"remote", fmt.Sprintf("%s:%s", remoteAddr, remotePort)}, {"interface", c.Interface}, {"inode", fmt.Sprintf("%d", c.Inode)}, } for _, f := range fields { val := f.value if val == "" || val == "0" || val == ":0" { val = SymbolDash } line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val) b.WriteString(line) } b.WriteString("\n") b.WriteString(" " + m.theme.Styles.Normal.Render("press esc to close") + "\n") return b.String() } func (m model) renderKillModal() string { if m.killTarget == nil { return "" } c := m.killTarget processName := c.Process if processName == "" { processName = "(unknown)" } // count how many connections this process has connCount := 0 for _, conn := range m.connections { if conn.PID == c.PID { connCount++ } } // build modal content var lines []string lines = append(lines, "") lines = append(lines, m.theme.Styles.Error.Render(" "+SymbolWarning+" KILL PROCESS? ")) lines = append(lines, "") lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName))) lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID)))) lines = append(lines, fmt.Sprintf(" user: %s", c.User)) lines = append(lines, fmt.Sprintf(" conns: %d", connCount)) lines = append(lines, "") lines = append(lines, m.theme.Styles.Warning.Render(" sends SIGTERM to process")) if connCount > 1 { lines = append(lines, m.theme.Styles.Warning.Render(fmt.Sprintf(" will close all %d connections", connCount))) } lines = append(lines, "") lines = append(lines, fmt.Sprintf(" %s confirm %s cancel", m.theme.Styles.Success.Render("[y]"), m.theme.Styles.Error.Render("[n]"))) lines = append(lines, "") return strings.Join(lines, "\n") } func (m model) renderExportModal() string { visible := m.visibleConnections() // count protocols and states for preview tcpCount, udpCount := 0, 0 listenCount, estabCount, otherCount := 0, 0, 0 for _, c := range visible { if c.Proto == "tcp" || c.Proto == "tcp6" { tcpCount++ } else { udpCount++ } switch c.State { case "LISTEN": listenCount++ case "ESTABLISHED": estabCount++ default: otherCount++ } } var lines []string // header lines = append(lines, "") headerText := " " + SymbolExport + " EXPORT CONNECTIONS " lines = append(lines, m.theme.Styles.Header.Render(headerText)) lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 36))) lines = append(lines, "") // stats preview section lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" summary")) lines = append(lines, fmt.Sprintf(" total: %s", m.theme.Styles.Success.Render(fmt.Sprintf("%d connections", len(visible))))) protoSummary := fmt.Sprintf(" proto: %s tcp %s udp", m.theme.Styles.GetProtoStyle("tcp").Render(fmt.Sprintf("%d", tcpCount)), m.theme.Styles.GetProtoStyle("udp").Render(fmt.Sprintf("%d", udpCount))) lines = append(lines, protoSummary) stateSummary := fmt.Sprintf(" state: %s listen %s estab %s other", m.theme.Styles.GetStateStyle("LISTEN").Render(fmt.Sprintf("%d", listenCount)), m.theme.Styles.GetStateStyle("ESTABLISHED").Render(fmt.Sprintf("%d", estabCount)), m.theme.Styles.Normal.Render(fmt.Sprintf("%d", otherCount))) lines = append(lines, stateSummary) lines = append(lines, "") // format selection lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" format")) csvStyle := m.theme.Styles.Normal tsvStyle := m.theme.Styles.Normal csvIndicator := " " tsvIndicator := " " if m.exportFormat == "tsv" { tsvStyle = m.theme.Styles.Success tsvIndicator = m.theme.Styles.Success.Render(SymbolSelected + " ") } else { csvStyle = m.theme.Styles.Success csvIndicator = m.theme.Styles.Success.Render(SymbolSelected + " ") } formatLine := fmt.Sprintf(" %s%s %s%s", csvIndicator, csvStyle.Render("CSV (comma)"), tsvIndicator, tsvStyle.Render("TSV (tab)")) lines = append(lines, formatLine) lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 8)+" press "+m.theme.Styles.Warning.Render("tab")+" to toggle")) lines = append(lines, "") // filename input lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" filename")) ext := ".csv" if m.exportFormat == "tsv" { ext = ".tsv" } filenameDisplay := m.exportFilename if filenameDisplay == "" { filenameDisplay = "connections" } inputBox := fmt.Sprintf(" %s %s%s", m.theme.Styles.Success.Render(SymbolPrompt), m.theme.Styles.Warning.Render(filenameDisplay), m.theme.Styles.Success.Render(ext+"▌")) lines = append(lines, inputBox) lines = append(lines, "") // error display if m.exportError != "" { lines = append(lines, m.theme.Styles.Error.Render(fmt.Sprintf(" %s %s", SymbolWarning, m.exportError))) lines = append(lines, "") } // preview of fields lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 36))) fieldsPreview := " fields: PID, PROCESS, USER, PROTO, STATE, LADDR, LPORT, RADDR, RPORT" lines = append(lines, m.theme.Styles.Normal.Render(truncate(fieldsPreview, 40))) lines = append(lines, "") // action buttons lines = append(lines, fmt.Sprintf(" %s export %s toggle format %s cancel", m.theme.Styles.Success.Render("[enter]"), m.theme.Styles.Warning.Render("[tab]"), m.theme.Styles.Error.Render("[esc]"))) lines = append(lines, "") return strings.Join(lines, "\n") } func (m model) overlayModal(background, modal string) string { bgLines := strings.Split(background, "\n") modalLines := strings.Split(modal, "\n") // find max modal line width using runewidth for proper unicode handling modalWidth := 0 for _, line := range modalLines { w := stringWidth(line) if w > modalWidth { modalWidth = w } } modalWidth += 4 // padding for box modalHeight := len(modalLines) boxWidth := modalWidth + 2 // include border chars │ │ // calculate modal position (centered) startRow := (m.height - modalHeight) / 2 if startRow < 2 { startRow = 2 } startCol := (m.width - boxWidth) / 2 if startCol < 0 { startCol = 0 } // build result result := make([]string, len(bgLines)) copy(result, bgLines) // ensure we have enough lines for len(result) < startRow+modalHeight+2 { result = append(result, strings.Repeat(" ", m.width)) } // helper to build a line with modal overlay buildLine := func(bgLine, modalContent string) string { modalVisibleWidth := stringWidth(modalContent) endCol := startCol + modalVisibleWidth leftBg := visibleSubstring(bgLine, 0, startCol) rightBg := visibleSubstring(bgLine, endCol, m.width) // pad left side if needed leftLen := stringWidth(leftBg) if leftLen < startCol { leftBg = leftBg + strings.Repeat(" ", startCol-leftLen) } return leftBg + modalContent + rightBg } // draw top border borderRow := startRow - 1 if borderRow >= 0 && borderRow < len(result) { border := m.theme.Styles.Border.Render(BoxTopLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxTopRight) result[borderRow] = buildLine(result[borderRow], border) } // draw modal content with side borders for i, line := range modalLines { row := startRow + i if row >= 0 && row < len(result) { content := line padding := modalWidth - stringWidth(line) if padding > 0 { content = line + strings.Repeat(" ", padding) } boxedLine := m.theme.Styles.Border.Render(BoxVertical) + content + m.theme.Styles.Border.Render(BoxVertical) result[row] = buildLine(result[row], boxedLine) } } // draw bottom border bottomRow := startRow + modalHeight if bottomRow >= 0 && bottomRow < len(result) { border := m.theme.Styles.Border.Render(BoxBottomLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxBottomRight) result[bottomRow] = buildLine(result[bottomRow], border) } return strings.Join(result, "\n") } // stringWidth returns the display width of a string excluding ANSI codes func stringWidth(s string) int { return runewidth.StringWidth(stripAnsi(s)) } // visibleSubstring extracts a substring by visible column positions, preserving ANSI codes func visibleSubstring(s string, start, end int) string { if start >= end { return "" } var result strings.Builder visiblePos := 0 inEscape := false for _, r := range s { // detect start of ANSI escape sequence if r == '\x1b' { inEscape = true result.WriteRune(r) continue } if inEscape { result.WriteRune(r) // end of escape sequence is a letter if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { inEscape = false } continue } // regular visible character w := runewidth.RuneWidth(r) if visiblePos >= start && visiblePos+w <= end { result.WriteRune(r) } visiblePos += w if visiblePos >= end { break } } return result.String() } func (m model) scrollOffset(pageSize, total int) int { if total <= pageSize { return 0 } // keep cursor roughly centered offset := m.cursor - pageSize/2 if offset < 0 { offset = 0 } if offset > total-pageSize { offset = total - pageSize } return offset } type columns struct { process int port int proto int state int local int remote int } func (m model) columnWidths() columns { // minimum widths (header lengths + padding) c := columns{ process: 7, // "PROCESS" port: 4, // "PORT" proto: 5, // "PROTO" state: 5, // "STATE" local: 5, // "LOCAL" remote: 6, // "REMOTE" } // scan visible connections to find max content width for each column visible := m.visibleConnections() for _, conn := range visible { if len(conn.Process) > c.process { c.process = len(conn.Process) } port := m.resolvePort(conn.Lport, conn.Proto) if len(port) > c.port { c.port = len(port) } if len(conn.Proto) > c.proto { c.proto = len(conn.Proto) } if len(conn.State) > c.state { c.state = len(conn.State) } local := m.resolveAddr(conn.Laddr) if len(local) > c.local { c.local = len(local) } remote := m.formatRemote(conn.Raddr, conn.Rport, conn.Proto) if len(remote) > c.remote { c.remote = len(remote) } } // calculate total and available width spacing := 12 // 2 spaces between each of 6 columns indicator := 2 margin := 2 available := m.safeWidth() - spacing - indicator - margin total := c.process + c.port + c.proto + c.state + c.local + c.remote // if content fits, we're done if total <= available { return c } // content exceeds available space - need to shrink columns proportionally // fixed columns that shouldn't shrink much: port, proto, state fixedWidth := c.port + c.proto + c.state flexibleAvailable := available - fixedWidth // distribute flexible space between process, local, remote flexibleTotal := c.process + c.local + c.remote if flexibleTotal > 0 && flexibleAvailable > 0 { ratio := float64(flexibleAvailable) / float64(flexibleTotal) c.process = max(7, int(float64(c.process)*ratio)) c.local = max(5, int(float64(c.local)*ratio)) c.remote = max(6, int(float64(c.remote)*ratio)) } return c } func (m model) safeWidth() int { if m.width < 80 { return 80 } return m.width } func formatDuration(d time.Duration) string { if d < time.Second { return fmt.Sprintf("%dms", d.Milliseconds()) } if d < time.Minute { return fmt.Sprintf("%.1fs", d.Seconds()) } return fmt.Sprintf("%.0fm", d.Minutes()) } func (m model) resolveAddr(addr string) string { if !m.resolveAddrs { return addr } if addr == "" || addr == "*" { return addr } return resolver.ResolveAddr(addr) } func (m model) resolvePort(port int, proto string) string { if !m.resolvePorts { return strconv.Itoa(port) } return resolver.ResolvePort(port, proto) } func (m model) formatRemote(addr string, port int, proto string) string { if addr == "" || addr == "*" || port == 0 { return "-" } resolvedAddr := m.resolveAddr(addr) resolvedPort := m.resolvePort(port, proto) return fmt.Sprintf("%s:%s", resolvedAddr, resolvedPort) }