Compare commits

..

2 Commits

Author SHA1 Message Date
Calmcacil
f0e26e4a0a bd sync: 2026-01-12 23:24:02 2026-01-12 23:24:02 +01:00
Calmcacil
dd62458515 Add text selection and copy capability to terminal UI 2026-01-12 23:23:48 +01:00
7 changed files with 106 additions and 20 deletions

File diff suppressed because one or more lines are too long

View File

@@ -223,6 +223,23 @@ Then run:
sudo ~/wireguard.sh load-clients sudo ~/wireguard.sh load-clients
``` ```
## Text Selection & Copying
To copy client configurations or other text from the terminal UI:
### Text Selection
- Hold **SHIFT key** while dragging your mouse with the left button
- This bypasses TUI mouse handling and enables your terminal's native text selection
- Then use your terminal's copy shortcut:
- **Linux/WSL**: Ctrl+Shift+C (or terminal-specific shortcut)
- **macOS**: Cmd+C
- **Windows**: Click right (or use terminal copy)
### Copy Buttons (when available)
- Some modals may have explicit copy buttons (e.g., "C" to copy config)
- These work when clipboard API is available (native Linux, macOS, WSL)
- Over SSH requires X11 forwarding (`ssh -X`) for clipboard access
## Client Setup ## Client Setup
### Importing the config ### Importing the config

View File

@@ -1,6 +1,7 @@
package components package components
import ( import (
"strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -263,7 +264,7 @@ func (m *SearchModel) HighlightMatches(value string) string {
Bold(true) Bold(true)
before := value[:index] before := value[:index]
match := value[index+len(query)] match := value[index : index+len(query)]
after := value[index+len(query):] after := value[index+len(query):]
return lipgloss.JoinHorizontal( return lipgloss.JoinHorizontal(

View File

@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
statusText := s.status statusText := s.status
if s.status == wireguard.StatusConnected { if s.status == wireguard.StatusConnected {
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status) duration := time.Since(s.lastHandshake)
quality := wireguard.CalculateQuality(duration)
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
} else { } else {
statusText = theme.StyleError.Bold(true).Render("● " + s.status) statusText = theme.StyleError.Bold(true).Render("● " + s.status)
} }

View File

@@ -41,7 +41,6 @@ func (s *HelpScreen) View() string {
// Breadcrumb: Help // Breadcrumb: Help
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}}) breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
// Styles // Styles
borderStyle := lipgloss.NewStyle(). borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
@@ -90,6 +89,11 @@ func (s *HelpScreen) View() string {
keyStyle.Render("/") + descStyle.Render("Search") + "\n" + keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
keyStyle.Render("q") + descStyle.Render("Quit") keyStyle.Render("q") + descStyle.Render("Quit")
copyGroup := categoryStyle.Render("Text Selection & Copy") + "\n" +
keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" +
keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" +
keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)")
// Two-column layout // Two-column layout
leftColumn := lipgloss.JoinVertical(lipgloss.Left, leftColumn := lipgloss.JoinVertical(lipgloss.Left,
navigationGroup, navigationGroup,
@@ -99,6 +103,8 @@ func (s *HelpScreen) View() string {
rightColumn := lipgloss.JoinVertical(lipgloss.Left, rightColumn := lipgloss.JoinVertical(lipgloss.Left,
otherGroup, otherGroup,
"",
copyGroup,
) )
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn) content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)

View File

@@ -30,14 +30,20 @@ var (
once sync.Once once sync.Once
// Global styles that can be used throughout the application // Global styles that can be used throughout the application
StylePrimary lipgloss.Style StylePrimary lipgloss.Style
StyleSuccess lipgloss.Style StyleSuccess lipgloss.Style
StyleWarning lipgloss.Style StyleWarning lipgloss.Style
StyleError lipgloss.Style StyleError lipgloss.Style
StyleMuted lipgloss.Style StyleMuted lipgloss.Style
StyleTitle lipgloss.Style StyleTitle lipgloss.Style
StyleSubtitle lipgloss.Style StyleSubtitle lipgloss.Style
StyleHelpKey lipgloss.Style StyleHelpKey lipgloss.Style
StyleValue lipgloss.Style
StyleDimmed lipgloss.Style
StyleTableHeader lipgloss.Style
StyleTableSelected lipgloss.Style
StyleBorder lipgloss.Color
StyleBackground lipgloss.Color
) )
// DefaultTheme is the standard blue-based theme // DefaultTheme is the standard blue-based theme
@@ -176,6 +182,33 @@ func ApplyTheme(theme *Theme) {
StyleHelpKey = lipgloss.NewStyle(). StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary). Foreground(theme.Scheme.Primary).
Bold(true) Bold(true)
// Value style for content values
StyleValue = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Dimmed style for overlay content
StyleDimmed = lipgloss.NewStyle().
Foreground(theme.Scheme.Muted)
// Table header style
StyleTableHeader = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
// Table selected style
StyleTableSelected = lipgloss.NewStyle().
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
// Border color
StyleBorder = lipgloss.Color("240")
// Background color for modals
StyleBackground = lipgloss.Color("235")
} }
// GetThemeNames returns a list of available theme names // GetThemeNames returns a list of available theme names

View File

@@ -15,6 +15,15 @@ const (
StatusConnected = "Connected" StatusConnected = "Connected"
// StatusDisconnected indicates a peer is not connected // StatusDisconnected indicates a peer is not connected
StatusDisconnected = "Disconnected" StatusDisconnected = "Disconnected"
// QualityExcellent indicates handshake was very recent (< 30s)
QualityExcellent = "Excellent"
// QualityGood indicates handshake was recent (< 2m)
QualityGood = "Good"
// QualityFair indicates handshake was acceptable (< 5m)
QualityFair = "Fair"
// QualityPoor indicates handshake was old (> 5m)
QualityPoor = "Poor"
) )
// PeerStatus represents the status of a WireGuard peer // PeerStatus represents the status of a WireGuard peer
@@ -25,7 +34,8 @@ type PeerStatus struct {
LatestHandshake time.Time `json:"latest_handshake"` LatestHandshake time.Time `json:"latest_handshake"`
TransferRx string `json:"transfer_rx"` TransferRx string `json:"transfer_rx"`
TransferTx string `json:"transfer_tx"` TransferTx string `json:"transfer_tx"`
Status string `json:"status"` // "Connected" or "Disconnected" Status string `json:"status"` // "Connected" or "Disconnected"
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
} }
// GetClientStatus checks if a specific client is connected // GetClientStatus checks if a specific client is connected
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
return peers return peers
} }
// CalculateQuality returns the connection quality based on handshake time
func CalculateQuality(timeSinceHandshake time.Duration) string {
if timeSinceHandshake < 30*time.Second {
return QualityExcellent
}
if timeSinceHandshake < 2*time.Minute {
return QualityGood
}
if timeSinceHandshake < 5*time.Minute {
return QualityFair
}
return QualityPoor
}
// finalizePeerStatus determines the peer's status based on handshake time // finalizePeerStatus determines the peer's status based on handshake time
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus { func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
peer.TransferRx = "" peer.TransferRx = ""
@@ -140,13 +164,16 @@ func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) Pee
} }
} }
// Determine status based on handshake // Determine status and quality based on handshake
if handshake != "" { if handshake != "" {
peer.LatestHandshake = parseHandshake(handshake) peer.LatestHandshake = parseHandshake(handshake)
timeSinceHandshake := time.Since(peer.LatestHandshake)
// Peer is considered connected if handshake is recent (within 5 minutes) // Peer is considered connected if handshake is recent (within 5 minutes)
// This allows for ~12 missed keepalive intervals (at 25 seconds each) // This allows for ~12 missed keepalive intervals (at 25 seconds each)
if time.Since(peer.LatestHandshake) < 5*time.Minute { if timeSinceHandshake < 5*time.Minute {
peer.Status = StatusConnected peer.Status = StatusConnected
peer.Quality = calculateQuality(timeSinceHandshake)
} else { } else {
peer.Status = StatusDisconnected peer.Status = StatusDisconnected
} }