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
```
## 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
### Importing the config

View File

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

View File

@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
statusText := s.status
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 {
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
}

View File

@@ -41,7 +41,6 @@ func (s *HelpScreen) View() string {
// Breadcrumb: Help
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
// Styles
borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
@@ -90,6 +89,11 @@ func (s *HelpScreen) View() string {
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
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
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
navigationGroup,
@@ -99,6 +103,8 @@ func (s *HelpScreen) View() string {
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
otherGroup,
"",
copyGroup,
)
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)

View File

@@ -30,14 +30,20 @@ var (
once sync.Once
// Global styles that can be used throughout the application
StylePrimary lipgloss.Style
StyleSuccess lipgloss.Style
StyleWarning lipgloss.Style
StyleError lipgloss.Style
StyleMuted lipgloss.Style
StyleTitle lipgloss.Style
StyleSubtitle lipgloss.Style
StyleHelpKey lipgloss.Style
StylePrimary lipgloss.Style
StyleSuccess lipgloss.Style
StyleWarning lipgloss.Style
StyleError lipgloss.Style
StyleMuted lipgloss.Style
StyleTitle lipgloss.Style
StyleSubtitle 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
@@ -176,6 +182,33 @@ func ApplyTheme(theme *Theme) {
StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary).
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

View File

@@ -15,6 +15,15 @@ const (
StatusConnected = "Connected"
// StatusDisconnected indicates a peer is not connected
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
@@ -25,7 +34,8 @@ type PeerStatus struct {
LatestHandshake time.Time `json:"latest_handshake"`
TransferRx string `json:"transfer_rx"`
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
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
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
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
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 != "" {
peer.LatestHandshake = parseHandshake(handshake)
timeSinceHandshake := time.Since(peer.LatestHandshake)
// Peer is considered connected if handshake is recent (within 5 minutes)
// 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.Quality = calculateQuality(timeSinceHandshake)
} else {
peer.Status = StatusDisconnected
}