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>`
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence
- **📝 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
- **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications
@@ -106,8 +106,9 @@ sshm
- `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup
- `H` - Toggle hidden hosts visibility
- `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:**
- 🟢 **Online** - Host is reachable via SSH
@@ -647,7 +648,7 @@ SSHM supports all standard SSH configuration options:
- `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `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:**
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
var forceTTY bool
// searchMode enables the focus on search mode at startup
var searchMode bool
// noUpdateCheck disables the async update check in the TUI
var noUpdateCheck bool
@@ -75,6 +72,8 @@ Examples:
return nil, cobra.ShellCompDirectiveError
}
hosts = config.FilterVisibleHosts(hosts)
var completions []string
toCompleteLower := strings.ToLower(toComplete)
for _, host := range hosts {
@@ -143,7 +142,7 @@ func runInteractiveMode() {
}
// 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)
}
}
@@ -242,7 +241,6 @@ func Execute() {
func init() {
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.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.SetVersionTemplate("{{.Name}} version {{.Version}}\n")

View File

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

View File

@@ -1624,6 +1624,27 @@ func GetAllConfigFiles() ([]string, error) {
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
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
if baseConfigPath == "" {

View File

@@ -438,7 +438,12 @@ func (m *addFormModel) renderGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
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()

View File

@@ -599,7 +599,12 @@ func (m *editFormModel) renderEditGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n")
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()

View File

@@ -81,6 +81,9 @@ func (m *helpModel) View() string {
lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "),
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,
m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")),

View File

@@ -70,8 +70,10 @@ func (p PortForwardType) String() string {
type Model struct {
table table.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
showHidden bool // when true, hidden-tagged hosts are shown
searchMode bool
deleteMode bool
deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
@@ -108,6 +110,14 @@ type Model struct {
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
func (m *Model) updateTableStyles() {
s := table.DefaultStyles()

View File

@@ -15,8 +15,9 @@ import (
"github.com/charmbracelet/lipgloss"
)
// 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 {
// NewModel creates a new TUI model with the given SSH hosts.
// 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
appConfig, err := config.LoadAppConfig()
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
m := Model{
hosts: hosts,
allHosts: hosts,
historyManager: historyManager,
pingManager: pingManager,
sortMode: SortByName,
@@ -60,20 +61,22 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
height: 24,
ready: false,
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
sortedHosts := m.sortHosts(hosts)
sortedHosts := m.sortHosts(visibleHosts)
// Create the search input
ti := textinput.New()
ti.Placeholder = "Search hosts or tags..."
ti.CharLimit = 50
ti.Width = 25
if searchMode {
ti.Focus()
}
ti.Focus()
// Use dynamic column width calculation (will fallback to static if width not available)
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
func RunInteractiveMode(hosts []config.SSHHost, configFile string, searchMode bool, currentVersion string, noUpdateCheck bool) error {
m := NewModel(hosts, configFile, searchMode, currentVersion, noUpdateCheck)
func RunInteractiveMode(hosts []config.SSHHost, configFile string, currentVersion string, noUpdateCheck bool) error {
m := NewModel(hosts, configFile, currentVersion, noUpdateCheck)
// Start the application in alt screen mode for clean output
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 {
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
if m.searchInput.Value() != "" {
@@ -231,7 +232,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil {
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
if m.searchInput.Value() != "" {
@@ -276,7 +278,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil {
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
if m.searchInput.Value() != "" {
@@ -535,7 +538,8 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus()
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
if m.searchInput.Value() != "" {
@@ -705,6 +709,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewMode = ViewHelp
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":
if !m.searchMode && !m.deleteMode {
// 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))
}
// 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
searchPrompt := "Search (/ to focus): "
if m.searchMode {