2 Commits

Author SHA1 Message Date
bd66da130e feat: focus search bar on startup by default
The search input is now focused when the TUI opens, so filtering works
immediately without an extra keystroke. This is especially helpful with
many hosts.

Removes the now-redundant -s/--search flag and updates the docs.
2026-07-09 12:24:10 +02:00
9c639206f7 feat: add hidden tag to hide hosts from TUI and search
Hosts tagged with "hidden" are excluded from the TUI list, shell
completions, and sshm search. Direct connections via sshm <host>
still work regardless of the tag.

A toggle key (H) shows or hides hidden hosts in the TUI, with a
yellow banner indicating the active state. The key is documented
in the help panel (h).

A contextual hint on the Tags field in the add and edit forms
reminds the user that "hidden" hides the host from the list.
2026-02-25 20:27:22 +01:00
11 changed files with 99 additions and 25 deletions

View File

@@ -30,7 +30,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>` - **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>`
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence - **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence
- **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly - **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly
- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization - **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization; use the special `hidden` tag to exclude hosts from the list while keeping them connectable
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
- **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications - **🔔 Smart Updates** - Automatic version checking with update notifications
@@ -106,8 +106,9 @@ sshm
- `d` - Delete selected host - `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives) - `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup - `f` - Port forwarding setup
- `H` - Toggle hidden hosts visibility
- `q` - Quit - `q` - Quit
- `/` - Search/filter hosts - `/` - Focus the search bar (already focused at startup; press `Enter` to jump back to the host list)
**Real-time Status Indicators:** **Real-time Status Indicators:**
- 🟢 **Online** - Host is reachable via SSH - 🟢 **Online** - Host is reachable via SSH
@@ -647,7 +648,7 @@ SSHM supports all standard SSH configuration options:
- `IdentityFile` - Path to private key file - `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) - `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`) - `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`)
- `Tags` - Custom tags (SSHM extension) - `Tags` - Custom tags (SSHM extension); the special tag `hidden` hides the host from the TUI and `sshm search` while keeping it connectable via `sshm <host>`
**Additional SSH Options:** **Additional SSH Options:**
You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format. You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format.

View File

@@ -24,9 +24,6 @@ var configFile string
// forceTTY forces pseudo-TTY allocation for remote commands // forceTTY forces pseudo-TTY allocation for remote commands
var forceTTY bool var forceTTY bool
// searchMode enables the focus on search mode at startup
var searchMode bool
// noUpdateCheck disables the async update check in the TUI // noUpdateCheck disables the async update check in the TUI
var noUpdateCheck bool var noUpdateCheck bool
@@ -75,6 +72,8 @@ Examples:
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
hosts = config.FilterVisibleHosts(hosts)
var completions []string var completions []string
toCompleteLower := strings.ToLower(toComplete) toCompleteLower := strings.ToLower(toComplete)
for _, host := range hosts { for _, host := range hosts {
@@ -143,7 +142,7 @@ func runInteractiveMode() {
} }
// Run the interactive TUI // Run the interactive TUI
if err := ui.RunInteractiveMode(hosts, configFile, searchMode, AppVersion, noUpdateCheck); err != nil { if err := ui.RunInteractiveMode(hosts, configFile, AppVersion, noUpdateCheck); err != nil {
log.Fatalf("Error running interactive mode: %v", err) log.Fatalf("Error running interactive mode: %v", err)
} }
} }
@@ -242,7 +241,6 @@ func Execute() {
func init() { func init() {
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)") RootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "SSH config file to use (default: ~/.ssh/config)")
RootCmd.Flags().BoolVarP(&forceTTY, "tty", "t", false, "Force pseudo-TTY allocation (useful for interactive remote commands)") RootCmd.Flags().BoolVarP(&forceTTY, "tty", "t", false, "Force pseudo-TTY allocation (useful for interactive remote commands)")
RootCmd.PersistentFlags().BoolVarP(&searchMode, "search", "s", false, "Focus on search input at startup")
RootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable automatic update check") RootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable automatic update check")
RootCmd.SetVersionTemplate("{{.Name}} version {{.Version}}\n") RootCmd.SetVersionTemplate("{{.Name}} version {{.Version}}\n")

View File

@@ -55,6 +55,9 @@ func runSearch(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Filter out hidden hosts
hosts = config.FilterVisibleHosts(hosts)
// Get search query // Get search query
var query string var query string
if len(args) > 0 { if len(args) > 0 {

View File

@@ -1624,6 +1624,27 @@ func GetAllConfigFiles() ([]string, error) {
return files, nil return files, nil
} }
// FilterVisibleHosts returns only hosts that do not have the "hidden" tag.
func FilterVisibleHosts(hosts []SSHHost) []SSHHost {
var visible []SSHHost
for _, h := range hosts {
if !hostHasTag(h.Tags, "hidden") {
visible = append(visible, h)
}
}
return visible
}
// hostHasTag reports whether the given tag list contains the target tag (case-insensitive).
func hostHasTag(tags []string, target string) bool {
for _, t := range tags {
if strings.EqualFold(t, target) {
return true
}
}
return false
}
// GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file // GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) { func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
if baseConfigPath == "" { if baseConfigPath == "" {

View File

@@ -438,7 +438,12 @@ func (m *addFormModel) renderGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n") b.WriteString("\n")
if field.index == tagsInput && m.focused == tagsInput {
b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`))
b.WriteString("\n")
}
b.WriteString("\n")
} }
return b.String() return b.String()

View File

@@ -599,7 +599,12 @@ func (m *editFormModel) renderEditGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) b.WriteString(m.inputs[field.index].View())
b.WriteString("\n\n") b.WriteString("\n")
if field.index == 7 && m.focusArea == focusAreaProperties && m.focused == 7 {
b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`))
b.WriteString("\n")
}
b.WriteString("\n")
} }
return b.String() return b.String()

View File

@@ -81,6 +81,9 @@ func (m *helpModel) View() string {
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "), m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")), m.styles.HelpText.Render("ping all hosts")),
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("H "),
m.styles.HelpText.Render("toggle hidden hosts visibility")),
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "), m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")), m.styles.HelpText.Render("setup port forwarding")),

View File

@@ -70,8 +70,10 @@ func (p PortForwardType) String() string {
type Model struct { type Model struct {
table table.Model table table.Model
searchInput textinput.Model searchInput textinput.Model
hosts []config.SSHHost allHosts []config.SSHHost // all parsed hosts, including hidden ones
hosts []config.SSHHost // visible hosts (filtered by showHidden)
filteredHosts []config.SSHHost filteredHosts []config.SSHHost
showHidden bool // when true, hidden-tagged hosts are shown
searchMode bool searchMode bool
deleteMode bool deleteMode bool
deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
@@ -108,6 +110,14 @@ type Model struct {
showingError bool showingError bool
} }
// applyVisibilityFilter returns hosts filtered according to the showHidden flag.
func (m Model) applyVisibilityFilter(hosts []config.SSHHost) []config.SSHHost {
if m.showHidden {
return hosts
}
return config.FilterVisibleHosts(hosts)
}
// updateTableStyles updates the table header border color based on focus state // updateTableStyles updates the table header border color based on focus state
func (m *Model) updateTableStyles() { func (m *Model) updateTableStyles() {
s := table.DefaultStyles() s := table.DefaultStyles()

View File

@@ -15,8 +15,9 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// NewModel creates a new TUI model with the given SSH hosts // NewModel creates a new TUI model with the given SSH hosts.
func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) Model { // The search input is focused at startup so users can filter hosts right away.
func NewModel(hosts []config.SSHHost, configFile string, currentVersion string, noUpdateCheck bool) Model {
// Load application configuration // Load application configuration
appConfig, err := config.LoadAppConfig() appConfig, err := config.LoadAppConfig()
if err != nil { if err != nil {
@@ -48,7 +49,7 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
// Create the model with default sorting by name // Create the model with default sorting by name
m := Model{ m := Model{
hosts: hosts, allHosts: hosts,
historyManager: historyManager, historyManager: historyManager,
pingManager: pingManager, pingManager: pingManager,
sortMode: SortByName, sortMode: SortByName,
@@ -60,20 +61,22 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
height: 24, height: 24,
ready: false, ready: false,
viewMode: ViewList, viewMode: ViewList,
searchMode: searchMode, searchMode: true,
} }
// Apply visibility filter (showHidden is false by default)
visibleHosts := m.applyVisibilityFilter(hosts)
m.hosts = visibleHosts
// Sort hosts according to the default sort mode // Sort hosts according to the default sort mode
sortedHosts := m.sortHosts(hosts) sortedHosts := m.sortHosts(visibleHosts)
// Create the search input // Create the search input
ti := textinput.New() ti := textinput.New()
ti.Placeholder = "Search hosts or tags..." ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50 ti.CharLimit = 50
ti.Width = 25 ti.Width = 25
if searchMode { ti.Focus()
ti.Focus()
}
// Use dynamic column width calculation (will fallback to static if width not available) // Use dynamic column width calculation (will fallback to static if width not available)
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts) nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
@@ -157,8 +160,8 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
} }
// RunInteractiveMode starts the interactive TUI interface // RunInteractiveMode starts the interactive TUI interface
func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) error { func RunInteractiveMode(hosts []config.SSHHost, configFile string, currentVersion string, noUpdateCheck bool) error {
m := NewModel(hosts, configFile, searchMode, currentVersion, noUpdateCheck) m := NewModel(hosts, configFile, currentVersion, noUpdateCheck)
// Start the application in alt screen mode for clean output // Start the application in alt screen mode for clean output
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())

View File

@@ -187,7 +187,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -231,7 +232,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -276,7 +278,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit return m, tea.Quit
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -535,7 +538,8 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil return m, nil
} }
m.hosts = m.sortHosts(hosts) m.allHosts = hosts
m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts))
// Reapply search filter if there is one active // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -705,6 +709,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewMode = ViewHelp m.viewMode = ViewHelp
return m, nil return m, nil
} }
case "H":
if !m.searchMode && !m.deleteMode {
// Toggle visibility of hidden hosts
m.showHidden = !m.showHidden
m.hosts = m.sortHosts(m.applyVisibilityFilter(m.allHosts))
if m.searchInput.Value() != "" {
m.filteredHosts = m.filterHosts(m.searchInput.Value())
} else {
m.filteredHosts = m.hosts
}
m.updateTableRows()
return m, nil
}
case "s": case "s":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now) // Cycle through sort modes (only 2 modes now)

View File

@@ -86,6 +86,14 @@ func (m Model) renderListView() string {
components = append(components, errorStyle.Render("❌ "+m.errorMessage)) components = append(components, errorStyle.Render("❌ "+m.errorMessage))
} }
// Add indicator when hidden hosts are shown
if m.showHidden {
hiddenBannerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).
Bold(true)
components = append(components, hiddenBannerStyle.Render(" [showing hidden hosts — press H to hide]"))
}
// Add the search bar with the appropriate style based on focus // Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): " searchPrompt := "Search (/ to focus): "
if m.searchMode { if m.searchMode {