Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
94 changes: 94 additions & 0 deletions cmd/thv/app/tui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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)

// Ensure the terminal background colour set by the TUI's OSC 11 sequence is
// always reset, even if the program exits via a panic rather than a clean
// quit. On a normal quit, View() emits the reset; this defer covers other
// exit paths. "\x1b]111;\x07" is the OSC 111 sequence that restores the
// terminal's default background colour.
defer func() { _, _ = fmt.Fprint(os.Stdout, "\x1b]111;\x07") }()

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
}
263 changes: 263 additions & 0 deletions cmd/thv/app/ui/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// 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())) { //nolint:gosec // uintptr fits int on all supported platforms
_ = 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")
fmt.Fprintf(&sb, " %s\n\n", brand)
for _, line := range strings.Split(strings.TrimSpace(cmd.Long), "\n") {
fmt.Fprintf(&sb, " %s\n", descStyle.Render(line))
}
sb.WriteString("\n")
fmt.Fprintf(&sb, " %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")
}

fmt.Fprintf(&sb, " %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())) { //nolint:gosec // uintptr fits int on all supported platforms
_ = cmd.Usage()
return
}

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

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

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

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

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

sb.WriteString("\n")
fmt.Fprintf(&sb, " %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 != "" {
fmt.Fprintf(&sb, " %s\n\n", lipgloss.NewStyle().Foreground(ColorDim2).Render(desc))
}

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

fmt.Fprintf(&sb, " %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
}
fmt.Fprintf(&sb, " %s%s\n", nameStyle.Render(sub.Name()), descStyle.Render(sub.Short))
}

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

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