Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/thv/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
rootCmd.AddCommand(groupCmd)
rootCmd.AddCommand(skillCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(tuiCmd)

// Silence printing the usage on error
rootCmd.SilenceUsage = true
Expand Down
87 changes: 87 additions & 0 deletions cmd/thv/app/tui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"fmt"
"log/slog"
"os"
"os/exec"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

"github.com/stacklok/toolhive/cmd/thv/app/ui"
"github.com/stacklok/toolhive/pkg/tui"
"github.com/stacklok/toolhive/pkg/workloads"
)

var tuiCmd = &cobra.Command{
Use: "tui",
Short: "Open the interactive TUI dashboard",
Long: `Launch the interactive terminal dashboard for managing MCP servers.

The dashboard shows a real-time list of servers with live log streaming,
tool inspection, and registry browsing — all from a single terminal window.

Key bindings:
↑/↓/j/k navigate servers or tools
tab cycle panels: Logs → Info → Tools → Proxy Logs → Inspector
s stop selected server
r restart selected server
d d delete selected server (press d twice)
/ filter server list, or search logs (on Logs/Proxy Logs panel)
n/N next/previous search match
f toggle log follow mode
←/→ horizontal scroll in log panels
R open registry browser
enter open tool in inspector (from Tools panel)
space toggle JSON node collapse (in inspector response)
c copy response JSON to clipboard
y copy curl command to clipboard
u copy server URL to clipboard
i show tool description (in inspector)
? show full help overlay
q/ctrl+c quit`,
RunE: tuiCmdFunc,
}

func tuiCmdFunc(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()

// Redirect slog WARN/ERROR to a channel so messages don't leak to stderr
// while the TUI is rendering in alt-screen mode.
tuiLogCh := make(chan string, 256)
origLogger := slog.Default()
slog.SetDefault(slog.New(ui.NewTUILogHandler(tuiLogCh, slog.LevelWarn)))
defer slog.SetDefault(origLogger)

manager, err := workloads.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create workload manager: %w", err)
}

model, err := tui.New(ctx, manager, tuiLogCh)
if err != nil {
return fmt.Errorf("failed to initialize TUI: %w", err)
}

p := tea.NewProgram(model, tea.WithAltScreen())
_, runErr := p.Run()

// BubbleTea puts the terminal in raw mode (OPOST/ONLCR disabled) and
// may not fully restore it before the shell regains control.
// Running "stty sane" is the most reliable way to reset all terminal
// flags (OPOST, ONLCR, ECHO, ICANON, …) back to safe defaults.
if stty := exec.Command("stty", "sane"); stty != nil {
stty.Stdin = os.Stdin
_ = stty.Run()
}

if runErr != nil {
return fmt.Errorf("TUI error: %w", runErr)
}

return nil
}
275 changes: 275 additions & 0 deletions cmd/thv/app/ui/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package ui

import (
"fmt"
"os"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"golang.org/x/term"
)

// commandEntry is a single entry in a help section.
type commandEntry struct {
name string
desc string
}

// helpSection groups commands under a heading.
type helpSection struct {
heading string
commands []commandEntry
}

// Root help sections — hardcoded for semantic ordering and grouping.
var rootHelpSections = []helpSection{
{
heading: "Servers",
commands: []commandEntry{
{"run", "Run an MCP server"},
{"start", "Start (resume) a stopped server"},
{"stop", "Stop an MCP server"},
{"restart", "Restart an MCP server"},
{"rm", "Remove an MCP server"},
{"list", "List running MCP servers"},
{"status", "Show detailed server status"},
{"logs", "View server logs"},
{"build", "Build a server image without running it"},
{"tui", "Open the interactive dashboard"},
},
},
{
heading: "Registry",
commands: []commandEntry{
{"registry", "Browse the MCP server registry"},
{"search", "Search registry for MCP servers"},
},
},
{
heading: "Clients",
commands: []commandEntry{
{"client", "Manage MCP client configurations"},
{"export", "Export server config for a client"},
{"mcp", "Interact with MCP servers for debugging"},
{"inspector", "Open the MCP inspector"},
},
},
{
heading: "Other",
commands: []commandEntry{
{"proxy", "Manage proxy settings"},
{"secret", "Manage secrets"},
{"group", "Manage server groups"},
{"skill", "Manage skills"},
{"config", "Manage application configuration"},
{"serve", "Start the ToolHive API server"},
{"runtime", "Container runtime commands"},
{"version", "Show version information"},
{"completion", "Generate shell completion scripts"},
},
},
}

// RenderHelp prints the styled help page.
// - Root command: 2-column command grid
// - Parent commands with subcommands: styled subcommand list
// - Non-TTY or leaf commands: falls back to cmd.Usage()
func RenderHelp(cmd *cobra.Command) {
if !term.IsTerminal(int(os.Stdout.Fd())) {
_ = cmd.Usage()
return
}

// Non-root parent command: show styled subcommand list.
if cmd.Parent() != nil && cmd.HasSubCommands() {
renderParentHelp(cmd)
return
}

// Non-root leaf command: fall back to Cobra default.
if cmd.Parent() != nil {
_ = cmd.Usage()
return
}

brand := lipgloss.NewStyle().
Foreground(ColorBlue).
Bold(true).
Render("ToolHive")

descStyle := lipgloss.NewStyle().Foreground(ColorDim2)
usageLine := lipgloss.NewStyle().
Foreground(ColorDim).
Render("Usage: thv <command> [flags]")

sectionHeading := lipgloss.NewStyle().
Foreground(ColorPurple).
Bold(true)

cmdName := lipgloss.NewStyle().
Foreground(ColorCyan).
Width(14)

cmdDesc := lipgloss.NewStyle().
Foreground(ColorDim2)

footerHint := lipgloss.NewStyle().
Foreground(ColorDim).
Render("Run thv <command> --help for details on a specific command.")

var sb strings.Builder

sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s\n\n", brand))
for _, line := range strings.Split(strings.TrimSpace(cmd.Long), "\n") {
sb.WriteString(fmt.Sprintf(" %s\n", descStyle.Render(line)))
}
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s\n\n", usageLine))

// Render sections in two columns
cols := [][]helpSection{
rootHelpSections[:2],
rootHelpSections[2:],
}

// Build each column as lines
colLines := make([][]string, 2)
for ci, sections := range cols {
for _, sec := range sections {
colLines[ci] = append(colLines[ci], fmt.Sprintf(" %s", sectionHeading.Render(sec.heading)))
for _, entry := range sec.commands {
line := fmt.Sprintf(" %s%s",
cmdName.Render(entry.name),
cmdDesc.Render(entry.desc),
)
colLines[ci] = append(colLines[ci], line)
}
colLines[ci] = append(colLines[ci], "")
}
}

// Interleave: print left column side-by-side with right column
maxRows := len(colLines[0])
if len(colLines[1]) > maxRows {
maxRows = len(colLines[1])
}

// Calculate column width from the actual content so nothing overflows.
colWidth := 0
for _, line := range colLines[0] {
if vl := VisibleLen(line); vl > colWidth {
colWidth = vl
}
}
colWidth += 4 // gap between columns

for i := range maxRows {
left := ""
right := ""
if i < len(colLines[0]) {
left = colLines[0][i]
}
if i < len(colLines[1]) {
right = colLines[1][i]
}
// Pad left column to colWidth visible chars (strip ANSI for width calc)
padded := PadToWidth(left, colWidth)
sb.WriteString(padded + right + "\n")
}

sb.WriteString(fmt.Sprintf(" %s\n\n", footerHint))

fmt.Print(sb.String())
}

// RenderCommandUsage prints a styled usage hint for a command when the user
// omits required arguments. Falls back to cmd.Usage() on non-TTY output.
func RenderCommandUsage(cmd *cobra.Command) {
if !term.IsTerminal(int(os.Stdout.Fd())) {
_ = cmd.Usage()
return
}

desc := cmd.Long
if desc == "" {
desc = cmd.Short
}

var sb strings.Builder
sb.WriteString("\n")

if desc != "" {
sb.WriteString(fmt.Sprintf(" %s\n\n",
lipgloss.NewStyle().Foreground(ColorDim2).Render(desc)))
}

sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorDim).Render("Usage:")))
sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorCyan).Render(cmd.UseLine())))

if cmd.Example != "" {
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorDim).Render("Examples:")))
for _, line := range strings.Split(strings.TrimRight(cmd.Example, "\n"), "\n") {
sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorDim2).Render(line)))
}
}

sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s\n\n",
lipgloss.NewStyle().Foreground(ColorDim).Render(
"Run thv "+cmd.Name()+" --help for more information.")))

fmt.Print(sb.String())
}

// renderParentHelp prints a styled subcommand list for a parent command.
func renderParentHelp(cmd *cobra.Command) {
var sb strings.Builder
sb.WriteString("\n")

desc := cmd.Long
if desc == "" {
desc = cmd.Short
}
if desc != "" {
sb.WriteString(fmt.Sprintf(" %s\n\n",
lipgloss.NewStyle().Foreground(ColorDim2).Render(desc)))
}

sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorDim).Render("Usage:")))
sb.WriteString(fmt.Sprintf(" %s\n\n",
lipgloss.NewStyle().Foreground(ColorCyan).Render("thv "+cmd.Name()+" <command> [flags]")))

sb.WriteString(fmt.Sprintf(" %s\n",
lipgloss.NewStyle().Foreground(ColorPurple).Bold(true).Render("Commands")))

nameStyle := lipgloss.NewStyle().Foreground(ColorCyan).Width(14)
descStyle := lipgloss.NewStyle().Foreground(ColorDim2)

for _, sub := range cmd.Commands() {
if sub.Hidden {
continue
}
sb.WriteString(fmt.Sprintf(" %s%s\n",
nameStyle.Render(sub.Name()),
descStyle.Render(sub.Short),
))
}

sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s\n\n",
lipgloss.NewStyle().Foreground(ColorDim).Render(
"Run thv "+cmd.Name()+" <command> --help for details.")))

fmt.Print(sb.String())
}
Loading
Loading