diff --git a/cmd/thv/app/commands.go b/cmd/thv/app/commands.go index 8bc78cc0a0..8c860d3ff2 100644 --- a/cmd/thv/app/commands.go +++ b/cmd/thv/app/commands.go @@ -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 diff --git a/cmd/thv/app/tui.go b/cmd/thv/app/tui.go new file mode 100644 index 0000000000..3f7c4c5695 --- /dev/null +++ b/cmd/thv/app/tui.go @@ -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 +} diff --git a/cmd/thv/app/ui/help.go b/cmd/thv/app/ui/help.go new file mode 100644 index 0000000000..2889b3b8f1 --- /dev/null +++ b/cmd/thv/app/ui/help.go @@ -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 [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 --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()+" [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()+" --help for details.")) + + fmt.Print(sb.String()) +} diff --git a/cmd/thv/app/ui/log_handler.go b/cmd/thv/app/ui/log_handler.go new file mode 100644 index 0000000000..f8b7b72736 --- /dev/null +++ b/cmd/thv/app/ui/log_handler.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + + "github.com/charmbracelet/lipgloss" +) + +// CLIHandler is a slog.Handler that renders WARN/ERROR records as styled +// single-line CLI messages instead of JSON or text key=value output. +// INFO and DEBUG records are silently dropped. +type CLIHandler struct { + mu sync.Mutex + w io.Writer + level slog.Level +} + +// NewCLIHandler returns a CLIHandler that writes to w and filters by level. +func NewCLIHandler(w io.Writer, level slog.Level) *CLIHandler { + return &CLIHandler{w: w, level: level} +} + +// Enabled reports whether the handler handles records at the given level. +func (h *CLIHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle formats a log record as a styled CLI message. +func (h *CLIHandler) Handle(_ context.Context, r slog.Record) error { + var icon string + var msgStyle, dimStyle lipgloss.Style + switch { + case r.Level >= slog.LevelError: + icon = lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render("✗") + msgStyle = lipgloss.NewStyle().Foreground(ColorRed) + dimStyle = lipgloss.NewStyle().Foreground(ColorRed) + default: // WARN + icon = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true).Render("⚠") + msgStyle = lipgloss.NewStyle().Foreground(ColorYellow) + dimStyle = lipgloss.NewStyle().Foreground(ColorDim2) + } + + text := msgStyle.Render(r.Message) + + // Append any "reason" attribute so the user sees why. + r.Attrs(func(a slog.Attr) bool { + if a.Key == "reason" || a.Key == "error" { + text += dimStyle.Render(": " + a.Value.String()) + } + return true + }) + + h.mu.Lock() + defer h.mu.Unlock() + _, _ = fmt.Fprintf(h.w, " %s %s\n", icon, text) + return nil +} + +// WithAttrs returns the same handler (attrs are omitted in CLI output). +func (h *CLIHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } + +// WithGroup returns the same handler (groups are omitted in CLI output). +func (h *CLIHandler) WithGroup(_ string) slog.Handler { return h } + +// TUILogHandler is a slog.Handler that sends formatted WARN/ERROR records to a +// channel so the TUI can display them inside the dashboard instead of writing +// to stderr (which would corrupt the alt-screen rendering). +type TUILogHandler struct { + ch chan<- string + level slog.Level +} + +// NewTUILogHandler creates a TUILogHandler that sends records to ch. +func NewTUILogHandler(ch chan<- string, level slog.Level) *TUILogHandler { + return &TUILogHandler{ch: ch, level: level} +} + +// Enabled reports whether the handler handles records at the given level. +func (h *TUILogHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle formats and sends a log record to the channel. +func (h *TUILogHandler) Handle(_ context.Context, r slog.Record) error { + prefix := func() string { + if r.Level >= slog.LevelError { + return "ERROR" + } + return "WARN" + }() + msg := prefix + ": " + r.Message + r.Attrs(func(a slog.Attr) bool { + msg += " " + a.Key + "=" + a.Value.String() + return true + }) + select { + case h.ch <- msg: + default: // drop if channel is full + } + return nil +} + +// WithAttrs returns the same handler (attrs are merged into Handle output). +func (h *TUILogHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } + +// WithGroup returns the same handler (groups are omitted). +func (h *TUILogHandler) WithGroup(_ string) slog.Handler { return h } diff --git a/cmd/thv/app/ui/spinner.go b/cmd/thv/app/ui/spinner.go new file mode 100644 index 0000000000..36b2360121 --- /dev/null +++ b/cmd/thv/app/ui/spinner.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +// Spinner is a simple TTY-only spinner that shows animated progress. +// All methods are no-ops when stdout is not a terminal. +type Spinner struct { + mu sync.Mutex + msg string + checkpointCh chan string // completed-step messages to print as ✓ lines + stopCh chan struct{} + doneCh chan struct{} +} + +// spinnerFrames are braille-pattern animation frames. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// NewSpinner creates a new Spinner with the given message. +func NewSpinner(msg string) *Spinner { + return &Spinner{ + msg: msg, + checkpointCh: make(chan string, 8), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Start launches the spinner goroutine. Call Stop or Fail to end it. +func (s *Spinner) Start() { + if !term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // uintptr fits int on all supported platforms + return + } + go func() { + defer close(s.doneCh) + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + i := 0 + for { + select { + case <-s.stopCh: + // Drain any pending checkpoints before exiting. + for { + select { + case doneMsg := <-s.checkpointCh: + printCheckpoint(doneMsg) + default: + return + } + } + case doneMsg := <-s.checkpointCh: + printCheckpoint(doneMsg) + case <-ticker.C: + frame := lipgloss.NewStyle().Foreground(ColorBlue).Render(spinnerFrames[i%len(spinnerFrames)]) + s.mu.Lock() + label := lipgloss.NewStyle().Foreground(ColorDim2).Render(s.msg) + s.mu.Unlock() + fmt.Printf("\r\033[K %s %s", frame, label) + i++ + } + } + }() +} + +// printCheckpoint prints a completed step as a ✓ line (called from the goroutine). +func printCheckpoint(doneMsg string) { + check := lipgloss.NewStyle().Foreground(ColorGreen).Bold(true).Render("✓") + msg := lipgloss.NewStyle().Foreground(ColorDim2).Render(doneMsg) + fmt.Printf("\r\033[K %s %s\n", check, msg) +} + +// Checkpoint commits the current step as done (prints ✓ doneMsg) and keeps +// the spinner running. Safe to call from any goroutine. +func (s *Spinner) Checkpoint(doneMsg string) { + if !term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // uintptr fits int on all supported platforms + return + } + s.checkpointCh <- doneMsg +} + +// Update changes the spinner message while it is running. +func (s *Spinner) Update(msg string) { + s.mu.Lock() + s.msg = msg + s.mu.Unlock() +} + +// Stop halts the spinner and prints a final success line. +func (s *Spinner) Stop(successMsg string) { + if !term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // uintptr fits int on all supported platforms + return + } + close(s.stopCh) + <-s.doneCh + check := lipgloss.NewStyle().Foreground(ColorGreen).Bold(true).Render("✓") + msg := lipgloss.NewStyle().Foreground(ColorText).Bold(true).Render(successMsg) + fmt.Printf("\r\033[K %s %s\n", check, msg) +} + +// Fail halts the spinner and prints a final error line. +func (s *Spinner) Fail(errMsg string) { + if !term.IsTerminal(int(os.Stdout.Fd())) { //nolint:gosec // uintptr fits int on all supported platforms + return + } + close(s.stopCh) + <-s.doneCh + cross := lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render("✗") + msg := lipgloss.NewStyle().Foreground(ColorRed).Render(errMsg) + fmt.Printf("\r\033[K %s %s\n", cross, msg) +} diff --git a/cmd/thv/app/ui/styles.go b/cmd/thv/app/ui/styles.go new file mode 100644 index 0000000000..9ba9ce7904 --- /dev/null +++ b/cmd/thv/app/ui/styles.go @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package ui provides shared styling helpers for the ToolHive CLI. +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + rt "github.com/stacklok/toolhive/pkg/container/runtime" +) + +// Tokyo Night palette +var ( + ColorGreen = lipgloss.Color("#9ece6a") + ColorRed = lipgloss.Color("#f7768e") + ColorYellow = lipgloss.Color("#e0af68") + ColorBlue = lipgloss.Color("#7aa2f7") + ColorPurple = lipgloss.Color("#bb9af7") + ColorCyan = lipgloss.Color("#7dcfff") + ColorDim = lipgloss.Color("#4a5070") + ColorDim2 = lipgloss.Color("#6272a4") + ColorText = lipgloss.Color("#c0caf5") + // ColorBg is the main TUI background — the same dark tone used by the statusbar. + ColorBg = lipgloss.Color("#1e2030") +) + +// Background shades for status pills. +var ( + bgRunning = lipgloss.Color("#1a3320") + bgStopped = lipgloss.Color("#1e2030") + bgError = lipgloss.Color("#3d1a1e") + bgStarting = lipgloss.Color("#2e2400") + bgWarning = lipgloss.Color("#2e2400") +) + +var ( + dotRunning = lipgloss.NewStyle().Foreground(ColorGreen).Render("●") + dotStopped = lipgloss.NewStyle().Foreground(ColorDim).Render("○") + dotError = lipgloss.NewStyle().Foreground(ColorRed).Render("●") + dotWarning = lipgloss.NewStyle().Foreground(ColorYellow).Render("●") + dotStarting = lipgloss.NewStyle().Foreground(ColorBlue).Render("◌") + + pillRunning = lipgloss.NewStyle(). + Background(bgRunning).Foreground(ColorGreen). + Padding(0, 1).Render("● running") + pillStopped = lipgloss.NewStyle(). + Background(bgStopped).Foreground(ColorDim2). + Padding(0, 1).Render("● stopped") + pillError = lipgloss.NewStyle(). + Background(bgError).Foreground(ColorRed). + Padding(0, 1).Render("● error") + pillStarting = lipgloss.NewStyle(). + Background(bgStarting).Foreground(ColorYellow). + Padding(0, 1).Render("◌ starting") + pillStopping = lipgloss.NewStyle(). + Background(bgWarning).Foreground(ColorYellow). + Padding(0, 1).Render("◌ stopping") + pillUnhealthy = lipgloss.NewStyle(). + Background(bgWarning).Foreground(ColorYellow). + Padding(0, 1).Render("● unhealthy") + pillRemoving = lipgloss.NewStyle(). + Background(bgWarning).Foreground(ColorYellow). + Padding(0, 1).Render("◌ removing") + pillUnknown = lipgloss.NewStyle(). + Background(bgStopped).Foreground(ColorDim). + Padding(0, 1).Render("○ unknown") + pillUnauthed = lipgloss.NewStyle(). + Background(bgWarning).Foreground(ColorYellow). + Padding(0, 1).Render("⚠ unauthed") + + keyStyle = lipgloss.NewStyle().Foreground(ColorDim2) + portStyle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(ColorDim) +) + +// PillWidth is the fixed visible width of a status pill (for column alignment). +const PillWidth = 13 // "● unhealthy" + 2 padding = longest + +// RenderStatusDot returns a colored bullet for the given WorkloadStatus. +func RenderStatusDot(status rt.WorkloadStatus) string { + switch status { + case rt.WorkloadStatusRunning: + return dotRunning + case rt.WorkloadStatusStopped: + return dotStopped + case rt.WorkloadStatusError: + return dotError + case rt.WorkloadStatusStarting: + return dotStarting + case rt.WorkloadStatusStopping: + return dotWarning + case rt.WorkloadStatusUnhealthy: + return dotWarning + case rt.WorkloadStatusUnauthenticated: + return dotWarning + case rt.WorkloadStatusRemoving: + return dotWarning + case rt.WorkloadStatusUnknown: + return dotStopped + } + return dotStopped +} + +// RenderStatusPill returns a badge with background color for the given status. +func RenderStatusPill(status rt.WorkloadStatus) string { + switch status { + case rt.WorkloadStatusRunning: + return pillRunning + case rt.WorkloadStatusStopped: + return pillStopped + case rt.WorkloadStatusError: + return pillError + case rt.WorkloadStatusStarting: + return pillStarting + case rt.WorkloadStatusStopping: + return pillStopping + case rt.WorkloadStatusUnhealthy: + return pillUnhealthy + case rt.WorkloadStatusRemoving: + return pillRemoving + case rt.WorkloadStatusUnknown: + return pillUnknown + case rt.WorkloadStatusUnauthenticated: + return pillUnauthed + default: + return pillUnknown + } +} + +// RenderGroupChip returns a bordered group name tag. +func RenderGroupChip(group string) string { + if group == "" { + return dimStyle.Render("—") + } + text := lipgloss.NewStyle().Foreground(ColorDim2).Render(group) + lbracket := lipgloss.NewStyle().Foreground(ColorDim).Render("[") + rbracket := lipgloss.NewStyle().Foreground(ColorDim).Render("]") + return lbracket + text + rbracket +} + +// RenderKey returns a dim-styled label for key-value displays. +func RenderKey(key string) string { + return keyStyle.Render(key) +} + +// RenderPort returns a bold cyan port number string. +func RenderPort(port string) string { + return portStyle.Render(port) +} + +// RenderDim returns a dim-styled string. +func RenderDim(s string) string { + return dimStyle.Render(s) +} + +// RenderText returns a text-colored string. +func RenderText(s string) string { + return lipgloss.NewStyle().Foreground(ColorText).Render(s) +} + +// VisibleLen returns the number of visible characters in s, stripping ANSI +// escape sequences and counting multi-byte UTF-8 codepoints as one character. +func VisibleLen(s string) int { + inEscape := false + count := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if inEscape { + if c == 'm' { + inEscape = false + } + continue + } + if c == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + inEscape = true + i++ // skip '[' + continue + } + // Skip UTF-8 continuation bytes (0x80–0xBF); count only leading bytes. + if c >= 0x80 && c <= 0xBF { + continue + } + count++ + } + return count +} + +// PadToWidth pads s (which may contain ANSI escapes) so its visible width equals w. +// If s is already wider, it is returned unchanged. +func PadToWidth(s string, w int) string { + visible := VisibleLen(s) + if visible >= w { + return s + } + return s + strings.Repeat(" ", w-visible) +} + +// RenderServerTypeBadge returns a styled badge for container vs remote server type. +func RenderServerTypeBadge(isRemote bool) string { + if isRemote { + return lipgloss.NewStyle(). + Background(lipgloss.Color("#1a1040")). + Foreground(ColorPurple). + Padding(0, 1). + Render("remote") + } + return lipgloss.NewStyle(). + Background(lipgloss.Color("#0d1a3a")). + Foreground(ColorBlue). + Padding(0, 1). + Render("container") +} + +// RenderTierBadge returns a styled badge for the registry tier. +func RenderTierBadge(tier string) string { + switch strings.ToLower(tier) { + case "official": + return lipgloss.NewStyle(). + Background(lipgloss.Color("#2e2400")). + Foreground(ColorYellow). + Padding(0, 1). + Render("official") + case "community": + return lipgloss.NewStyle(). + Background(lipgloss.Color("#1e2030")). + Foreground(ColorDim2). + Padding(0, 1). + Render("community") + case "deprecated": + return lipgloss.NewStyle(). + Background(bgError). + Foreground(ColorRed). + Padding(0, 1). + Render("deprecated") + default: + return lipgloss.NewStyle(). + Foreground(ColorDim). + Render(tier) + } +} + +// RenderStars returns a yellow star count string. +func RenderStars(n int) string { + if n == 0 { + return lipgloss.NewStyle().Foreground(ColorDim).Render("—") + } + return lipgloss.NewStyle().Foreground(ColorYellow).Render(fmt.Sprintf("★ %d", n)) +} + +// RenderLogLine colorizes a log line based on detected severity level. +func RenderLogLine(line string) string { + upper := strings.ToUpper(line) + switch { + case containsLevel(upper, "ERROR", "FATAL", "CRIT"): + return lipgloss.NewStyle().Foreground(ColorRed).Render(line) + case containsLevel(upper, "WARN", "WARNING"): + return lipgloss.NewStyle().Foreground(ColorYellow).Render(line) + case containsLevel(upper, "DEBUG", "TRACE"): + return lipgloss.NewStyle().Foreground(ColorDim2).Render(line) + case containsLevel(upper, "INFO"): + return lipgloss.NewStyle().Foreground(ColorText).Render(line) + default: + return lipgloss.NewStyle().Foreground(ColorDim2).Render(line) + } +} + +// containsLevel checks whether the line contains one of the given level tokens. +func containsLevel(upper string, levels ...string) bool { + for _, lvl := range levels { + // Match common patterns: level=INFO, [INFO], INFO:, INFO space + if strings.Contains(upper, "LEVEL="+lvl) || + strings.Contains(upper, "["+lvl+"]") || + strings.Contains(upper, lvl+":") || + strings.Contains(upper, " "+lvl+" ") || + strings.HasPrefix(upper, lvl+" ") { + return true + } + } + return false +} + +// RenderSection renders a section heading (e.g. "Permissions"). +func RenderSection(title string) string { + return "\n" + lipgloss.NewStyle().Foreground(ColorPurple).Bold(true).Render(title) +} + +// PadLeftToWidth right-aligns s within width w by prepending spaces. +// If s is already wider, it is returned unchanged. +func PadLeftToWidth(s string, w int) string { + visible := VisibleLen(s) + if visible >= w { + return s + } + return strings.Repeat(" ", w-visible) + s +} diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 9f6b4ac6db..f48c535f3f 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -56,5 +56,6 @@ thv [flags] * [thv start](thv_start.md) - Start (resume) a tooling server * [thv status](thv_status.md) - Show detailed status of an MCP server * [thv stop](thv_stop.md) - Stop one or more MCP servers +* [thv tui](thv_tui.md) - Open the interactive TUI dashboard * [thv version](thv_version.md) - Show the version of ToolHive diff --git a/docs/cli/thv_tui.md b/docs/cli/thv_tui.md new file mode 100644 index 0000000000..c1a632480b --- /dev/null +++ b/docs/cli/thv_tui.md @@ -0,0 +1,62 @@ +--- +title: thv tui +hide_title: true +description: Reference for ToolHive CLI command `thv tui` +last_update: + author: autogenerated +slug: thv_tui +mdx: + format: md +--- + +## thv tui + +Open the interactive TUI dashboard + +### Synopsis + +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 + +``` +thv tui [flags] +``` + +### Options + +``` + -h, --help help for tui +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers + diff --git a/go.mod b/go.mod index 0de0463448..5a4c9626ee 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,16 @@ require ( dario.cat/mergo v1.0.2 github.com/1password/onepassword-sdk-go v0.3.1 github.com/alicebob/miniredis/v2 v2.37.0 + github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 github.com/cedar-policy/cedar-go v1.6.0 github.com/cenkalti/backoff/v5 v5.0.3 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/containerd/errdefs v1.0.0 github.com/coreos/go-oidc/v3 v3.17.0 github.com/docker/docker v28.5.2+incompatible @@ -102,10 +105,9 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.10.2 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect diff --git a/go.sum b/go.sum index fd4dcd29d3..735c885330 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= @@ -112,18 +114,20 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= -github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= diff --git a/pkg/tui/actions.go b/pkg/tui/actions.go new file mode 100644 index 0000000000..4bba531b4e --- /dev/null +++ b/pkg/tui/actions.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// actionDoneMsg is sent when a stop/restart/delete action completes. +type actionDoneMsg struct { + action string // "stopped", "restarted", "deleted" + name string // workload name + err error +} + +// stopWorkload returns a tea.Cmd that stops the named workload. +func stopWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.StopWorkloads(ctx, []string{name}) + if err != nil { + return actionDoneMsg{action: "stopped", name: name, err: err} + } + return actionDoneMsg{action: "stopped", name: name, err: fn()} + } +} + +// deleteWorkload returns a tea.Cmd that removes the named workload. +func deleteWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.DeleteWorkloads(ctx, []string{name}) + if err != nil { + return actionDoneMsg{action: "deleted", name: name, err: err} + } + return actionDoneMsg{action: "deleted", name: name, err: fn()} + } +} + +// restartWorkload returns a tea.Cmd that restarts the named workload. +func restartWorkload(ctx context.Context, manager workloads.Manager, name string) tea.Cmd { + return func() tea.Msg { + fn, err := manager.RestartWorkloads(ctx, []string{name}, false) + if err != nil { + return actionDoneMsg{action: "restarted", name: name, err: err} + } + return actionDoneMsg{action: "restarted", name: name, err: fn()} + } +} + +// runFormResultMsg is sent when a "run from registry" command completes. +type runFormResultMsg struct { + name string + server string + err error +} + +// runFromRegistry returns a tea.Cmd that runs a registry server via the thv CLI. +func runFromRegistry( + ctx context.Context, + item regtypes.ServerMetadata, + workloadName string, + secrets, envs map[string]string, +) tea.Cmd { + return func() tea.Msg { + exe, err := os.Executable() + if err != nil { + return runFormResultMsg{name: workloadName, server: item.GetName(), err: fmt.Errorf("find executable: %w", err)} + } + + serverName := item.GetName() + args := []string{"run", serverName, "--name", workloadName} + + // Transport only when non-default. + const defaultTransport = "streamable-http" + if t := item.GetTransport(); t != "" && t != defaultTransport { + args = append(args, "--transport", t) + } + + // Permission profile from ImageMetadata. + if img, ok := item.(*regtypes.ImageMetadata); ok && img != nil && img.Permissions != nil { + if name := img.Permissions.Name; name != "" && name != "none" { + args = append(args, "--permission-profile", name) + } + } + + // Secret env vars are passed as --env NAME=VALUE because the user entered + // actual values into the form. The --secret flag is for named secrets + // stored in the secrets manager (format: NAME,target=TARGET), which is a + // different flow. Using --env avoids both the wrong format and the risk of + // leaking values via process args with an incorrect flag. + for k, v := range secrets { + if strings.ContainsRune(k, '=') { + return runFormResultMsg{name: workloadName, server: serverName, + err: fmt.Errorf("invalid secret name %q: must not contain '='", k)} + } + args = append(args, "--env", k+"="+v) + } + + // Env vars. + for k, v := range envs { + if strings.ContainsRune(k, '=') { + return runFormResultMsg{name: workloadName, server: serverName, + err: fmt.Errorf("invalid env var name %q: must not contain '='", k)} + } + args = append(args, "--env", k+"="+v) + } + + cmd := exec.CommandContext(ctx, exe, args...) //nolint:gosec // executable path is resolved via os.Executable, not user input + out, err := cmd.CombinedOutput() + if err != nil { + return runFormResultMsg{ + name: workloadName, + server: serverName, + err: fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))), + } + } + return runFormResultMsg{name: workloadName, server: serverName} + } +} diff --git a/pkg/tui/form_helpers.go b/pkg/tui/form_helpers.go new file mode 100644 index 0000000000..cd3e39df83 --- /dev/null +++ b/pkg/tui/form_helpers.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// formNextField advances focus to the next field in a formField slice (wraps around). +func formNextField(fields []formField, idx *int) { + if len(fields) == 0 { + return + } + if *idx >= 0 { + fields[*idx].input.Blur() + } + *idx = (*idx + 1) % len(fields) + fields[*idx].input.Focus() +} + +// formPrevField moves focus to the previous field in a formField slice (wraps around). +func formPrevField(fields []formField, idx *int) { + if len(fields) == 0 { + return + } + if *idx >= 0 { + fields[*idx].input.Blur() + } + if *idx <= 0 { + *idx = len(fields) - 1 + } else { + *idx-- + } + fields[*idx].input.Focus() +} + +// formBlurAll blurs every field and resets the focused index to -1. +func formBlurAll(fields []formField, idx *int) { + for i := range fields { + fields[i].input.Blur() + } + *idx = -1 +} + +// formForwardKey forwards a key message to the currently focused field. +func formForwardKey(fields []formField, idx int, msg tea.KeyMsg) tea.Cmd { + if idx < 0 || idx >= len(fields) { + return nil + } + var cmd tea.Cmd + fields[idx].input, cmd = fields[idx].input.Update(msg) + return cmd +} diff --git a/pkg/tui/form_helpers_test.go b/pkg/tui/form_helpers_test.go new file mode 100644 index 0000000000..5fff5db163 --- /dev/null +++ b/pkg/tui/form_helpers_test.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/stretchr/testify/assert" +) + +func makeFormFields() []formField { + const n = 3 + fields := make([]formField, n) + for i := range fields { + fields[i] = formField{input: textinput.New(), name: "field"} + } + return fields +} + +func TestFormNextField(t *testing.T) { + t.Parallel() + + t.Run("empty slice is safe", func(t *testing.T) { + t.Parallel() + idx := 0 + formNextField(nil, &idx) // should not panic + assert.Equal(t, 0, idx) + }) + + t.Run("advances from -1 to 0", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := -1 + formNextField(fields, &idx) + assert.Equal(t, 0, idx) + }) + + t.Run("wraps around from last to first", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 2 + // Focus field 2 so Blur can be called + fields[2].input.Focus() + formNextField(fields, &idx) + assert.Equal(t, 0, idx) + }) + + t.Run("advances sequentially", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 0 + fields[0].input.Focus() + formNextField(fields, &idx) + assert.Equal(t, 1, idx) + }) +} + +func TestFormPrevField(t *testing.T) { + t.Parallel() + + t.Run("empty slice is safe", func(t *testing.T) { + t.Parallel() + idx := 0 + formPrevField(nil, &idx) // should not panic + assert.Equal(t, 0, idx) + }) + + t.Run("wraps from 0 to last", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 0 + fields[0].input.Focus() + formPrevField(fields, &idx) + assert.Equal(t, 2, idx) + }) + + t.Run("wraps from -1 to last", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := -1 + formPrevField(fields, &idx) + assert.Equal(t, 2, idx) + }) + + t.Run("moves backwards sequentially", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 2 + fields[2].input.Focus() + formPrevField(fields, &idx) + assert.Equal(t, 1, idx) + }) +} + +func TestFormBlurAll(t *testing.T) { + t.Parallel() + + t.Run("resets idx to -1", func(t *testing.T) { + t.Parallel() + fields := makeFormFields() + idx := 1 + fields[1].input.Focus() + formBlurAll(fields, &idx) + assert.Equal(t, -1, idx) + }) + + t.Run("empty fields safe", func(t *testing.T) { + t.Parallel() + idx := 5 + formBlurAll(nil, &idx) + assert.Equal(t, -1, idx) + }) +} diff --git a/pkg/tui/helpers_test.go b/pkg/tui/helpers_test.go new file mode 100644 index 0000000000..0c404360de --- /dev/null +++ b/pkg/tui/helpers_test.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" +) + +func TestWrapText(t *testing.T) { + t.Parallel() + tests := []struct { + name string + text string + maxW int + indent string + expected []string + }{ + { + name: "empty string", + text: "", + maxW: 40, + indent: "", + expected: nil, + }, + { + name: "single word shorter than maxW", + text: "hello", + maxW: 40, + indent: " ", + expected: []string{" hello"}, + }, + { + name: "wraps at word boundary", + text: "hello world foo bar", + maxW: 12, + indent: "", + expected: []string{"hello world", "foo bar"}, + }, + { + name: "word longer than maxW stays on its own line", + text: "superlongword short", + maxW: 5, + indent: "", + expected: []string{"superlongword", "short"}, + }, + { + name: "unicode characters counted as runes", + text: "\u4f60\u597d \u4e16\u754c \u6d4b\u8bd5 \u6587\u672c", + maxW: 7, + indent: "", + expected: []string{"\u4f60\u597d \u4e16\u754c", "\u6d4b\u8bd5 \u6587\u672c"}, + }, + { + name: "indent prefix included in width calculation", + text: "aaa bbb ccc", + maxW: 8, + indent: ">>> ", + expected: []string{">>> aaa", ">>> bbb", ">>> ccc"}, + }, + { + name: "whitespace-only input", + text: " ", + maxW: 40, + indent: "", + expected: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, wrapText(tc.text, tc.maxW, tc.indent)) + }) + } +} + +func TestRunesTruncate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + n int + expected string + }{ + { + name: "no truncation needed", + input: "hello", + n: 10, + expected: "hello", + }, + { + name: "exact length", + input: "hello", + n: 5, + expected: "hello", + }, + { + name: "truncated with ellipsis", + input: "hello world", + n: 5, + expected: "hell\u2026", + }, + { + name: "unicode input truncated", + input: "\u4f60\u597d\u4e16\u754c\u6d4b\u8bd5", + n: 3, + expected: "\u4f60\u597d\u2026", + }, + { + name: "n equals 1 gives just ellipsis", + input: "hello", + n: 1, + expected: "\u2026", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, runesTruncate(tc.input, tc.n)) + }) + } +} + +func TestTruncateSidebar(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + n int + expected string + }{ + { + name: "n <= 0 returns original", + input: "hello", + n: 0, + expected: "hello", + }, + { + name: "negative n returns original", + input: "hello", + n: -5, + expected: "hello", + }, + { + name: "exact length no truncation", + input: "hello", + n: 5, + expected: "hello", + }, + { + name: "truncated with ellipsis", + input: "hello world", + n: 5, + expected: "hell\u2026", + }, + { + name: "short string not truncated", + input: "hi", + n: 10, + expected: "hi", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, truncateSidebar(tc.input, tc.n)) + }) + } +} + +func TestCountStatuses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + workloads []core.Workload + expectedRunning int + expectedStopped int + }{ + { + name: "empty list", + workloads: nil, + expectedRunning: 0, + expectedStopped: 0, + }, + { + name: "all running variants counted as running", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusUnauthenticated}, + {Status: rt.WorkloadStatusUnhealthy}, + }, + expectedRunning: 3, + expectedStopped: 0, + }, + { + name: "all stopped variants counted as stopped", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusStopped}, + {Status: rt.WorkloadStatusError}, + {Status: rt.WorkloadStatusStarting}, + {Status: rt.WorkloadStatusStopping}, + {Status: rt.WorkloadStatusRemoving}, + {Status: rt.WorkloadStatusUnknown}, + }, + expectedRunning: 0, + expectedStopped: 6, + }, + { + name: "mixed statuses", + workloads: []core.Workload{ + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusStopped}, + {Status: rt.WorkloadStatusRunning}, + {Status: rt.WorkloadStatusError}, + }, + expectedRunning: 2, + expectedStopped: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + running, stopped := countStatuses(tc.workloads) + assert.Equal(t, tc.expectedRunning, running) + assert.Equal(t, tc.expectedStopped, stopped) + }) + } +} diff --git a/pkg/tui/init.go b/pkg/tui/init.go new file mode 100644 index 0000000000..d5492ffd1b --- /dev/null +++ b/pkg/tui/init.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/viewport" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// New creates a new TUI model. +// logCh (optional) receives slog WARN/ERROR messages captured while the TUI runs. +func New(ctx context.Context, manager workloads.Manager, logCh <-chan string) (Model, error) { + // Fetch initial workload list + list, err := manager.ListWorkloads(ctx, true) + if err != nil { + return Model{}, fmt.Errorf("failed to list workloads: %w", err) + } + core.SortWorkloadsByName(list) + + vp := viewport.New(80, 20) + vp.SetContent("") + + pvp := viewport.New(80, 20) + pvp.SetContent("") + + tvp := viewport.New(80, 20) + tvp.SetContent("") + + ivp := viewport.New(60, 20) + ivp.SetContent("") + + lvp := viewport.New(60, 6) + lvp.SetContent("") + + m := Model{ + ctx: ctx, + manager: manager, + workloads: list, + panel: panelLogs, + logView: vp, + logFollow: true, + proxyLogView: pvp, + toolsView: tvp, + insp: inspectorState{ + respView: ivp, + logView: lvp, + fieldIdx: -1, + }, + tuiLogCh: logCh, + } + + return m, nil +} diff --git a/pkg/tui/inspector.go b/pkg/tui/inspector.go new file mode 100644 index 0000000000..83517e3ca5 --- /dev/null +++ b/pkg/tui/inspector.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// inspSpinFrames holds the spinner animation frames for the inspector loading state. +var inspSpinFrames = []string{"⠋", "⠙", "⠸", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// inspCallResultMsg is sent when a tool call completes in the inspector. +type inspCallResultMsg struct { + result *vmcp.ToolCallResult + elapsedMs int64 + err error +} + +// inspSpinTickMsg is sent on each spinner tick while loading. +type inspSpinTickMsg struct{} + +// buildInspFields parses a tool's InputSchema and returns form fields. +// It extracts properties from the JSON Schema and creates textinput models. +func buildInspFields(tool vmcp.Tool) []formField { + props, _ := tool.InputSchema["properties"].(map[string]any) + if props == nil { + return nil + } + + reqSet := buildRequiredSet(tool.InputSchema) + + // Iterate in a stable order by collecting keys first. + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + slices.Sort(keys) + + var fields []formField + for _, name := range keys { + def, ok := props[name].(map[string]any) + if !ok { + continue + } + + fieldType, _ := def["type"].(string) + if fieldType == "" { + fieldType = "string" + } + desc, _ := def["description"].(string) + + ti := textinput.New() + ti.Placeholder = fieldType + if reqSet[name] { + ti.Placeholder = fieldType + " (required)" + } + ti.Width = 40 + + fields = append(fields, formField{ + input: ti, + name: name, + required: reqSet[name], + desc: desc, + typeName: fieldType, + }) + } + + return fields +} + +// buildRequiredSet returns a set of required field names from a JSON Schema. +func buildRequiredSet(schema map[string]any) map[string]bool { + reqSet := map[string]bool{} + reqArr, _ := schema["required"].([]any) + for _, r := range reqArr { + if s, ok := r.(string); ok { + reqSet[s] = true + } + } + return reqSet +} + +// shellEscapeSingleQuote escapes single quotes for safe inclusion inside +// a single-quoted shell string: ' → '"'"' (end quote, escaped quote, reopen). +func shellEscapeSingleQuote(s string) string { + return strings.ReplaceAll(s, "'", `'"'"'`) +} + +// buildCurlStr constructs a curl command string for the given tool call. +func buildCurlStr(sel *core.Workload, toolName string, args map[string]any) string { + if sel == nil { + return "" + } + + url := sel.URL + if url == "" { + url = fmt.Sprintf("http://127.0.0.1:%d/sse", sel.Port) + } + + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": toolName, + "arguments": args, + }, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + payloadJSON = []byte("{}") + } + + return fmt.Sprintf("curl -X POST \\\n '%s' \\\n -H 'Content-Type: application/json' \\\n -d '%s'", + shellEscapeSingleQuote(url), shellEscapeSingleQuote(string(payloadJSON))) +} + +// startInspCallTool returns a tea.Cmd that calls a tool asynchronously. +func startInspCallTool(ctx context.Context, sel *core.Workload, toolName string, args map[string]any) tea.Cmd { + wCopy := *sel + return func() tea.Msg { + start := time.Now() + result, err := callTool(ctx, &wCopy, toolName, args) + elapsed := time.Since(start).Milliseconds() + return inspCallResultMsg{result: result, elapsedMs: elapsed, err: err} + } +} + +// callTool invokes a tool on the backend MCP server. +func callTool(ctx context.Context, workload *core.Workload, toolName string, args map[string]any) (*vmcp.ToolCallResult, error) { + mcpClient, target, err := newBackendClientAndTarget(ctx, workload) + if err != nil { + return nil, err + } + + return mcpClient.CallTool(ctx, target, toolName, args, nil) +} + +// inspFieldValues builds a map[string]any from current form field input values, skipping empty fields. +func inspFieldValues(fields []formField) map[string]any { + result := make(map[string]any) + for _, f := range fields { + v := strings.TrimSpace(f.input.Value()) + if v == "" { + continue + } + result[f.name] = v + } + return result +} + +// formatInspResult formats a ToolCallResult as a pretty-printed JSON string. +func formatInspResult(result *vmcp.ToolCallResult) string { + if result == nil { + return "" + } + parts := make([]string, 0, len(result.Content)) + for _, c := range result.Content { + switch c.Type { + case vmcp.ContentTypeText: + parts = append(parts, c.Text) + case vmcp.ContentTypeImage, vmcp.ContentTypeAudio, vmcp.ContentTypeResource, vmcp.ContentTypeLink: + b, _ := json.MarshalIndent(c, "", " ") + parts = append(parts, string(b)) + } + } + if len(parts) == 0 { + b, _ := json.MarshalIndent(result, "", " ") + return string(b) + } + return strings.Join(parts, "\n") +} diff --git a/pkg/tui/inspector_test.go b/pkg/tui/inspector_test.go new file mode 100644 index 0000000000..83142d4587 --- /dev/null +++ b/pkg/tui/inspector_test.go @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +func TestBuildRequiredSet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + schema map[string]any + expected map[string]bool + }{ + { + name: "missing required key", + schema: map[string]any{"properties": map[string]any{}}, + expected: map[string]bool{}, + }, + { + name: "empty required array", + schema: map[string]any{"required": []any{}}, + expected: map[string]bool{}, + }, + { + name: "valid required strings", + schema: map[string]any{ + "required": []any{"name", "url"}, + }, + expected: map[string]bool{"name": true, "url": true}, + }, + { + name: "non-string elements skipped", + schema: map[string]any{ + "required": []any{"name", 42, true, nil, "url"}, + }, + expected: map[string]bool{"name": true, "url": true}, + }, + { + name: "required is wrong type entirely", + schema: map[string]any{"required": "not-an-array"}, + expected: map[string]bool{}, + }, + { + name: "nil schema", + schema: nil, + expected: map[string]bool{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, buildRequiredSet(tc.schema)) + }) + } +} + +func TestInspFieldValues(t *testing.T) { + t.Parallel() + + makeField := func(name, value string) formField { + ti := textinput.New() + ti.SetValue(value) + return formField{input: ti, name: name} + } + + tests := []struct { + name string + fields []formField + expected map[string]any + }{ + { + name: "empty fields", + fields: nil, + expected: map[string]any{}, + }, + { + name: "empty values skipped", + fields: []formField{ + makeField("a", ""), + makeField("b", " "), + }, + expected: map[string]any{}, + }, + { + name: "whitespace trimmed", + fields: []formField{ + makeField("url", " https://example.com "), + }, + expected: map[string]any{"url": "https://example.com"}, + }, + { + name: "multiple fields collected", + fields: []formField{ + makeField("name", "test"), + makeField("empty", ""), + makeField("count", "42"), + }, + expected: map[string]any{"name": "test", "count": "42"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, inspFieldValues(tc.fields)) + }) + } +} + +func TestShellEscapeSingleQuote(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {"no quotes", "hello", "hello"}, + {"single quote", "it's", `it'"'"'s`}, + {"multiple quotes", "a'b'c", `a'"'"'b'"'"'c`}, + {"empty string", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, shellEscapeSingleQuote(tc.input)) + }) + } +} + +func TestBuildCurlStr(t *testing.T) { + t.Parallel() + tests := []struct { + name string + workload *core.Workload + toolName string + args map[string]any + check func(t *testing.T, result string) + }{ + { + name: "nil workload returns empty", + workload: nil, + check: func(t *testing.T, result string) { + t.Helper() + assert.Empty(t, result) + }, + }, + { + name: "single quote in arg value is escaped", + workload: &core.Workload{Name: "test", URL: "http://localhost:8080/sse", Port: 8080}, + toolName: "echo", + args: map[string]any{"msg": "it's dangerous"}, + check: func(t *testing.T, result string) { + t.Helper() + assert.NotContains(t, result, "'it's", "unescaped single quote in payload") + assert.Contains(t, result, "curl -X POST") + }, + }, + { + name: "single quote in URL is escaped", + workload: &core.Workload{Name: "test", URL: "http://localhost:8080/path'inject", Port: 8080}, + toolName: "echo", + args: map[string]any{}, + check: func(t *testing.T, result string) { + t.Helper() + assert.NotContains(t, result, "'http://localhost:8080/path'inject'", + "unescaped single quote in URL") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := buildCurlStr(tc.workload, tc.toolName, tc.args) + tc.check(t, result) + }) + } +} + +func TestFormatInspResult(t *testing.T) { + t.Parallel() + tests := []struct { + name string + result *vmcp.ToolCallResult + expected string + }{ + { + name: "nil result", + result: nil, + expected: "", + }, + { + name: "single text content", + result: &vmcp.ToolCallResult{ + Content: []vmcp.Content{ + {Type: "text", Text: "hello world"}, + }, + }, + expected: "hello world", + }, + { + name: "multiple text contents joined", + result: &vmcp.ToolCallResult{ + Content: []vmcp.Content{ + {Type: "text", Text: "line1"}, + {Type: "text", Text: "line2"}, + }, + }, + expected: "line1\nline2", + }, + { + name: "non-text content serialized as JSON", + result: &vmcp.ToolCallResult{ + Content: []vmcp.Content{ + {Type: "image", Data: "base64data", MimeType: "image/png"}, + }, + }, + }, + { + name: "empty content falls back to full result JSON", + result: &vmcp.ToolCallResult{ + Content: []vmcp.Content{}, + IsError: true, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := formatInspResult(tc.result) + if tc.expected != "" { + assert.Equal(t, tc.expected, got) + } else if tc.result != nil { + // For non-text and empty-content cases, just verify it returns non-empty valid output + require.NotEmpty(t, got) + } + }) + } +} diff --git a/pkg/tui/json_tree.go b/pkg/tui/json_tree.go new file mode 100644 index 0000000000..549d261af0 --- /dev/null +++ b/pkg/tui/json_tree.go @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// jsonNodeKind identifies the JSON value type of a tree node. +type jsonNodeKind int8 + +const ( + kindObject jsonNodeKind = iota + kindArray + kindString + kindNumber + kindBool + kindNull +) + +// jsonNode is a node in a parsed JSON tree. +type jsonNode struct { + kind jsonNodeKind + key string // non-empty when this is an object field + value string // rendered value for primitive types + children []*jsonNode + collapsed bool + isLast bool // last child in parent — no trailing comma +} + +// visItem is an entry in the flattened visible-node list produced by flattenVisible. +type visItem struct { + node *jsonNode + depth int + closingBracket bool // true → render the closing } or ] for this node's parent +} + +// parseJSONTree parses a JSON string into a jsonNode tree. +// Returns nil if the input is not valid JSON or not an object/array at the root. +func parseJSONTree(s string) *jsonNode { + trimmed := strings.TrimSpace(s) + if len(trimmed) == 0 { + return nil + } + // Only attempt tree rendering for objects and arrays. + if trimmed[0] != '{' && trimmed[0] != '[' { + return nil + } + var raw any + if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { + return nil + } + return buildJSONNode(raw, "", true) +} + +// buildJSONNode recursively converts an unmarshalled value into a jsonNode tree. +func buildJSONNode(v any, key string, isLast bool) *jsonNode { + node := &jsonNode{key: key, isLast: isLast} + switch val := v.(type) { + case map[string]any: + node.kind = kindObject + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + slices.Sort(keys) + for i, k := range keys { + child := buildJSONNode(val[k], k, i == len(keys)-1) + node.children = append(node.children, child) + } + case []any: + node.kind = kindArray + for i, item := range val { + child := buildJSONNode(item, "", i == len(val)-1) + node.children = append(node.children, child) + } + case string: + node.kind = kindString + node.value = fmt.Sprintf("%q", val) + case float64: + node.kind = kindNumber + if val == float64(int64(val)) { + node.value = fmt.Sprintf("%d", int64(val)) + } else { + node.value = fmt.Sprintf("%g", val) + } + case bool: + node.kind = kindBool + if val { + node.value = "true" + } else { + node.value = "false" + } + case nil: + node.kind = kindNull + node.value = "null" + } + return node +} + +// flattenVisible returns a flat DFS-ordered list of all currently visible nodes. +// Closing-bracket entries are appended after each expanded object/array's children. +func flattenVisible(root *jsonNode) []visItem { + var out []visItem + appendVisible(root, 0, &out) + return out +} + +func appendVisible(node *jsonNode, depth int, out *[]visItem) { + *out = append(*out, visItem{node: node, depth: depth}) + if node.collapsed || len(node.children) == 0 { + return + } + for _, child := range node.children { + appendVisible(child, depth+1, out) + } + // Append closing bracket line at the same depth as the opening line. + *out = append(*out, visItem{node: node, depth: depth, closingBracket: true}) +} + +// toggleCollapse toggles the collapsed state of the node at the given cursor position. +// Both the opening line and the closing-bracket line of an object/array toggle it. +func toggleCollapse(vis []visItem, cursor int) { + if cursor < 0 || cursor >= len(vis) { + return + } + node := vis[cursor].node + if node.kind == kindObject || node.kind == kindArray { + node.collapsed = !node.collapsed + } +} + +// renderJSONTree renders a windowed view of the visible list, highlighting the cursor item. +// width is the available column width; visH is the number of lines to render. +func renderJSONTree(vis []visItem, cursor, scrollOff, width, visH int) string { + if len(vis) == 0 { + return "" + } + cursorBg := lipgloss.Color("#2a2e45") + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + end := scrollOff + visH + if end > len(vis) { + end = len(vis) + } + for i := scrollOff; i < end; i++ { + line := renderJSONItem(vis[i]) + if i == cursor { + line = lipgloss.NewStyle(). + Background(cursorBg). + Width(width - 2). + Render(line) + } + sb.WriteString(line + "\n") + } + // Scroll position indicator when content overflows. + if len(vis) > visH { + pct := 0 + if len(vis) > 1 { + pct = (cursor * 100) / (len(vis) - 1) + } + sb.WriteString(dimStyle.Render(fmt.Sprintf(" ─ %d/%d %d%% ─", cursor+1, len(vis), pct)) + "\n") + } + return sb.String() +} + +// nodeToAny reconstructs a Go value from a jsonNode tree (for re-serialization). +func nodeToAny(node *jsonNode) any { + switch node.kind { + case kindObject: + m := make(map[string]any, len(node.children)) + for _, child := range node.children { + m[child.key] = nodeToAny(child) + } + return m + case kindArray: + s := make([]any, len(node.children)) + for i, child := range node.children { + s[i] = nodeToAny(child) + } + return s + case kindString: + var s string + _ = json.Unmarshal([]byte(node.value), &s) + return s + case kindNumber: + var n float64 + _ = json.Unmarshal([]byte(node.value), &n) + return n + case kindBool: + return node.value == "true" + case kindNull: + return nil + } + return nil +} + +// nodeToJSON serializes the selected node back to indented JSON. +func nodeToJSON(node *jsonNode) string { + b, err := json.MarshalIndent(nodeToAny(node), "", " ") + if err != nil { + return "" + } + return string(b) +} + +// renderJSONItem converts a single visItem to a syntax-colored terminal line. +// +//nolint:gocyclo // switch on kind + collapsed/empty sub-cases; splitting would obscure the rendering logic +func renderJSONItem(item visItem) string { + node := item.node + indent := strings.Repeat(" ", item.depth) + + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + dim2Style := lipgloss.NewStyle().Foreground(ui.ColorDim2) + keyStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + strStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + numStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow) + boolStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple) + + comma := "" + if !node.isLast { + comma = textStyle.Render(",") + } + + // Closing bracket line (}, ]). + if item.closingBracket { + bracket := func() string { + if node.kind == kindObject { + return "}" + } + return "]" + }() + return indent + textStyle.Render(bracket) + comma + } + + // Key prefix for object fields. + keyPart := "" + if node.key != "" { + keyPart = keyStyle.Render(fmt.Sprintf("%q", node.key)) + textStyle.Render(": ") + } + + // Collapse/expand toggle indicator for objects and arrays. + toggle := "" + if node.kind == kindObject || node.kind == kindArray { + if node.collapsed { + toggle = dimStyle.Render("▶ ") + } else { + toggle = dimStyle.Render("▼ ") + } + } + + switch node.kind { + case kindObject: + if node.collapsed { + return indent + toggle + keyPart + dim2Style.Render(fmt.Sprintf("{…%d}", len(node.children))) + comma + } + if len(node.children) == 0 { + return indent + toggle + keyPart + textStyle.Render("{}") + comma + } + return indent + toggle + keyPart + textStyle.Render("{") + case kindArray: + if node.collapsed { + return indent + toggle + keyPart + dim2Style.Render(fmt.Sprintf("[…%d]", len(node.children))) + comma + } + if len(node.children) == 0 { + return indent + toggle + keyPart + textStyle.Render("[]") + comma + } + return indent + toggle + keyPart + textStyle.Render("[") + case kindString: + return indent + keyPart + strStyle.Render(node.value) + comma + case kindNumber: + return indent + keyPart + numStyle.Render(node.value) + comma + case kindBool: + return indent + keyPart + boolStyle.Render(node.value) + comma + case kindNull: + return indent + keyPart + dimStyle.Render(node.value) + comma + } + return indent + keyPart + node.value + comma +} diff --git a/pkg/tui/json_tree_test.go b/pkg/tui/json_tree_test.go new file mode 100644 index 0000000000..1a3edca9c2 --- /dev/null +++ b/pkg/tui/json_tree_test.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseJSONTree(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expectNil bool + expectKind jsonNodeKind + }{ + { + name: "empty string", + input: "", + expectNil: true, + }, + { + name: "scalar string", + input: `"hello"`, + expectNil: true, + }, + { + name: "scalar number", + input: `42`, + expectNil: true, + }, + { + name: "invalid JSON", + input: `{broken`, + expectNil: true, + }, + { + name: "valid object", + input: `{"key": "value"}`, + expectKind: kindObject, + }, + { + name: "valid array", + input: `[1, 2, 3]`, + expectKind: kindArray, + }, + { + name: "empty object", + input: `{}`, + expectKind: kindObject, + }, + { + name: "whitespace around valid object", + input: ` {"a": 1} `, + expectKind: kindObject, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := parseJSONTree(tc.input) + if tc.expectNil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, tc.expectKind, result.kind) + } + }) + } +} + +func TestFlattenVisible(t *testing.T) { + t.Parallel() + + t.Run("flat object", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1, "b": "two"}`) + require.NotNil(t, root) + + vis := flattenVisible(root) + // root opening + 2 children + root closing = 4 + assert.Len(t, vis, 4) + // First item is the root object opening + assert.Equal(t, kindObject, vis[0].node.kind) + assert.False(t, vis[0].closingBracket) + // Last item is the closing bracket + assert.True(t, vis[len(vis)-1].closingBracket) + }) + + t.Run("nested object", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"outer": {"inner": 1}}`) + require.NotNil(t, root) + + vis := flattenVisible(root) + // root{ + outer{ + inner + outer} + root} = 5 + assert.Len(t, vis, 5) + // Check depths + assert.Equal(t, 0, vis[0].depth) // root opening + assert.Equal(t, 1, vis[1].depth) // outer opening + assert.Equal(t, 2, vis[2].depth) // inner value + assert.Equal(t, 1, vis[3].depth) // outer closing + assert.Equal(t, 0, vis[4].depth) // root closing + }) + + t.Run("collapsed nodes skip children", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": {"b": 1}, "c": 2}`) + require.NotNil(t, root) + + // Collapse the "a" child (first child of root) + root.children[0].collapsed = true + + vis := flattenVisible(root) + // root{ + collapsed-a + c + root} = 4 (no "b", no closing for "a") + assert.Len(t, vis, 4) + }) +} + +func TestToggleCollapse(t *testing.T) { + t.Parallel() + + t.Run("toggle on object works", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + assert.False(t, root.collapsed) + toggleCollapse(vis, 0) // toggle root object + assert.True(t, root.collapsed) + toggleCollapse(vis, 0) // toggle back + assert.False(t, root.collapsed) + }) + + t.Run("toggle on scalar is noop", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + // vis[1] is the "a": 1 scalar child + child := vis[1].node + assert.Equal(t, kindNumber, child.kind) + toggleCollapse(vis, 1) // should be noop + assert.False(t, child.collapsed) + }) + + t.Run("out of bounds is safe", func(t *testing.T) { + t.Parallel() + root := parseJSONTree(`{"a": 1}`) + require.NotNil(t, root) + vis := flattenVisible(root) + + // These should not panic + toggleCollapse(vis, -1) + toggleCollapse(vis, len(vis)) + toggleCollapse(nil, 0) + }) +} + +func TestNodeToJSON(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + }{ + { + name: "simple object roundtrip", + input: `{"name":"test","value":42}`, + }, + { + name: "array roundtrip", + input: `[1,2,3]`, + }, + { + name: "nested structure roundtrip", + input: `{"items":[{"id":1},{"id":2}],"total":2}`, + }, + { + name: "booleans and null", + input: `{"active":true,"deleted":false,"meta":null}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + root := parseJSONTree(tc.input) + require.NotNil(t, root) + + output := nodeToJSON(root) + + // Parse both to compare structurally (formatting may differ) + var expected, actual any + require.NoError(t, json.Unmarshal([]byte(tc.input), &expected)) + require.NoError(t, json.Unmarshal([]byte(output), &actual)) + assert.Equal(t, expected, actual) + }) + } +} diff --git a/pkg/tui/keys.go b/pkg/tui/keys.go new file mode 100644 index 0000000000..e3c566fc78 --- /dev/null +++ b/pkg/tui/keys.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import "github.com/charmbracelet/bubbles/key" + +// keyMap holds all key bindings for the TUI. +type keyMap struct { + Up key.Binding + Down key.Binding + Tab key.Binding + ShiftTab key.Binding + Stop key.Binding + Restart key.Binding + Delete key.Binding + Filter key.Binding + Help key.Binding + Quit key.Binding + Enter key.Binding + Escape key.Binding + Follow key.Binding + Registry key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Space key.Binding // toggle JSON node collapse + CopyNode key.Binding // copy response JSON to clipboard (c) + CopyCurl key.Binding // copy curl command to clipboard (y) + SearchNext key.Binding // n — next search match in logs + SearchPrev key.Binding // N — previous search match in logs + CopyURL key.Binding // u — copy workload URL to clipboard + ToolInfo key.Binding // i — show tool description modal +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "navigate up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "navigate down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch panel"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "previous field"), + ), + Stop: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "stop"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + Follow: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "follow logs"), + ), + Registry: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "registry"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "scroll right"), + ), + Space: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle collapse"), + ), + CopyNode: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy node JSON"), + ), + CopyCurl: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "copy curl"), + ), + SearchNext: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "next match"), + ), + SearchPrev: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("N", "prev match"), + ), + CopyURL: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "copy URL"), + ), + ToolInfo: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "tool info"), + ), +} diff --git a/pkg/tui/logformat.go b/pkg/tui/logformat.go new file mode 100644 index 0000000000..65e11fef31 --- /dev/null +++ b/pkg/tui/logformat.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + xansi "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// formatLogLine parses a structured JSON log line (slog format) and returns a +// human-readable, colorized string. Non-JSON lines are returned unchanged. +func formatLogLine(raw string) string { + raw = strings.TrimRight(raw, "\r\n") + if len(raw) == 0 || raw[0] != '{' { + return raw + } + + var entry map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + return raw + } + + ts := extractStr(entry, "time") + if len(ts) >= 19 { + ts = ts[11:19] // HH:MM:SS + } + level := strings.ToUpper(extractStr(entry, "level")) + msg := extractStr(entry, "msg") + + // Collect remaining fields sorted for stable output. + skip := map[string]bool{"time": true, "level": true, "msg": true} + var extras []string + for k, v := range entry { + if skip[k] { + continue + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + extras = append(extras, fmt.Sprintf("%s=%s", k, s)) + } else { + extras = append(extras, fmt.Sprintf("%s=%s", k, string(v))) + } + } + sort.Strings(extras) + + dim := lipgloss.NewStyle().Foreground(ui.ColorDim) + + // Message and extras color depend on log level. + msgColor := ui.ColorText + extrasColor := ui.ColorDim2 + switch level { + case "ERROR": + msgColor = ui.ColorRed + extrasColor = ui.ColorRed + case "WARN": + msgColor = ui.ColorYellow + extrasColor = ui.ColorYellow + } + + var sb strings.Builder + sb.WriteString(dim.Render(ts)) + sb.WriteString(" ") + sb.WriteString(levelStyle(level)) + sb.WriteString(" ") + sb.WriteString(lipgloss.NewStyle().Foreground(msgColor).Render(msg)) + if len(extras) > 0 { + sb.WriteString(" ") + sb.WriteString(lipgloss.NewStyle().Foreground(extrasColor).Render(strings.Join(extras, " "))) + } + return sb.String() +} + +func extractStr(m map[string]json.RawMessage, key string) string { + v, ok := m[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(v, &s); err != nil { + return string(v) + } + return s +} + +func levelStyle(level string) string { + label := fmt.Sprintf("%-5s", level) + switch level { + case "ERROR": + return lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render(label) + case "WARN": + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render(label) + case "INFO": + return lipgloss.NewStyle().Foreground(ui.ColorBlue).Render(label) + default: + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(label) + } +} + +// buildHScrollContent builds viewport content applying horizontal scroll. +// Each line is ANSI-cut to [hOff, hOff+viewW] so no wrapping occurs. +func buildHScrollContent(lines []string, viewW, hOff int) string { + if len(lines) == 0 { + return "" + } + if viewW <= 0 || (hOff == 0 && viewW >= 512) { + return strings.Join(lines, "\n") + } + var sb strings.Builder + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(xansi.Cut(line, hOff, hOff+viewW)) + } + return sb.String() +} diff --git a/pkg/tui/logformat_test.go b/pkg/tui/logformat_test.go new file mode 100644 index 0000000000..46f07b0007 --- /dev/null +++ b/pkg/tui/logformat_test.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatLogLine(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + expectContains []string + }{ + { + name: "non-JSON passthrough", + raw: "plain log line", + expectContains: []string{"plain log line"}, + }, + { + name: "empty string", + raw: "", + expectContains: []string{""}, + }, + { + name: "invalid JSON passthrough", + raw: "{not valid json", + expectContains: []string{"{not valid json"}, + }, + { + name: "valid slog JSON extracts message", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"INFO","msg":"server started"}`, + expectContains: []string{"10:30:45", "INFO", "server started"}, + }, + { + name: "extra fields included in output", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"ERROR","msg":"failed","component":"proxy"}`, + expectContains: []string{"ERROR", "failed", "component=proxy"}, + }, + { + name: "short timestamp handled gracefully", + raw: `{"time":"short","level":"WARN","msg":"test"}`, + expectContains: []string{"WARN", "test"}, + }, + { + name: "trailing CR stripped", + raw: `{"time":"2025-01-15T10:30:45.123Z","level":"DEBUG","msg":"ok"}` + "\r", + expectContains: []string{"ok"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := formatLogLine(tc.raw) + for _, substr := range tc.expectContains { + assert.Contains(t, result, substr) + } + }) + } +} + +func TestLevelStyle(t *testing.T) { + t.Parallel() + levels := []string{"ERROR", "WARN", "INFO", "DEBUG", "TRACE", ""} + for _, level := range levels { + t.Run("level_"+level, func(t *testing.T) { + t.Parallel() + result := levelStyle(level) + assert.NotEmpty(t, result, "levelStyle should return non-empty for level %q", level) + }) + } +} diff --git a/pkg/tui/logs.go b/pkg/tui/logs.go new file mode 100644 index 0000000000..ee2b39aeaa --- /dev/null +++ b/pkg/tui/logs.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/workloads" +) + +const ( + logPollInterval = 500 * time.Millisecond + logFetchLines = 50 +) + +// StreamWorkloadLogs starts a goroutine that polls manager.GetLogs for the +// given workload name and sends new log lines to the returned channel. +// Cancel the context to stop streaming. +func StreamWorkloadLogs(ctx context.Context, manager workloads.Manager, name string) <-chan string { + ch := make(chan string, 256) + go func() { + defer close(ch) + var lastLines []string + for { + select { + case <-ctx.Done(): + return + case <-time.After(logPollInterval): + } + + raw, err := manager.GetLogs(ctx, name, false, logFetchLines) + if err != nil { + continue + } + + lines := splitLines(raw) + newLines := diffLines(lastLines, lines) + lastLines = lines + + for _, l := range newLines { + select { + case ch <- l: + case <-ctx.Done(): + return + } + } + } + }() + return ch +} + +// splitLines splits a string into non-empty lines. +func splitLines(s string) []string { + all := strings.Split(s, "\n") + out := make([]string, 0, len(all)) + for _, l := range all { + if l != "" { + out = append(out, l) + } + } + return out +} + +// diffLines returns lines in next that are not in prev (suffix-based). +// This detects new lines appended since the last poll. +// +// To handle duplicate log lines reliably, we match a suffix of prev (up to +// diffMatchLen lines) as a contiguous sequence in next, rather than matching +// only the last line. This makes false positional matches exponentially +// less likely when the same log message repeats. +func diffLines(prev, next []string) []string { + if len(next) == 0 { + return nil + } + if len(prev) == 0 { + return next + } + + // Take the last few lines of prev as the match sequence. + matchLen := diffMatchLen + if matchLen > len(prev) { + matchLen = len(prev) + } + suffix := prev[len(prev)-matchLen:] + + // Scan next from the end looking for the suffix sequence. + for i := len(next) - matchLen; i >= 0; i-- { + if slicesEqual(next[i:i+matchLen], suffix) { + return next[i+matchLen:] + } + } + + // No sequence match — fall back to single-line match on the last prev line. + lastPrev := prev[len(prev)-1] + for i := len(next) - 1; i >= 0; i-- { + if next[i] == lastPrev { + return next[i+1:] + } + } + + // No overlap found — return all lines in next. + return next +} + +// diffMatchLen is the number of trailing lines from the previous poll used +// to anchor the suffix match. Higher values reduce false matches with +// duplicate lines but require more overlap to detect the boundary. +const diffMatchLen = 3 + +// slicesEqual returns true if a and b have the same length and contents. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/tui/logs_test.go b/pkg/tui/logs_test.go new file mode 100644 index 0000000000..f9e7e29e5b --- /dev/null +++ b/pkg/tui/logs_test.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitLines(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "single line no newline", + input: "hello", + expected: []string{"hello"}, + }, + { + name: "trailing newline skipped", + input: "hello\nworld\n", + expected: []string{"hello", "world"}, + }, + { + name: "multiple empty lines filtered", + input: "a\n\n\nb", + expected: []string{"a", "b"}, + }, + { + name: "carriage return not stripped by splitLines", + input: "hello\r\nworld\r\n", + expected: []string{"hello\r", "world\r"}, + }, + { + name: "only newlines", + input: "\n\n\n", + expected: []string{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, splitLines(tc.input)) + }) + } +} + +func TestDiffLines(t *testing.T) { + t.Parallel() + tests := []struct { + name string + prev []string + next []string + expected []string + }{ + { + name: "prev empty returns all next", + prev: nil, + next: []string{"a", "b"}, + expected: []string{"a", "b"}, + }, + { + name: "next empty returns nil", + prev: []string{"a"}, + next: nil, + expected: nil, + }, + { + name: "both empty", + prev: nil, + next: nil, + expected: nil, + }, + { + name: "full overlap no new lines", + prev: []string{"a", "b", "c"}, + next: []string{"a", "b", "c"}, + expected: []string{}, + }, + { + name: "partial overlap returns new tail", + prev: []string{"a", "b"}, + next: []string{"a", "b", "c", "d"}, + expected: []string{"c", "d"}, + }, + { + name: "no overlap returns all next", + prev: []string{"x", "y"}, + next: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "duplicate last line resolved by suffix sequence match", + prev: []string{"a", "b"}, + next: []string{"a", "b", "b", "c"}, + expected: []string{"b", "c"}, + }, + { + name: "single-line prev falls back to last-line match", + prev: []string{"b"}, + next: []string{"b", "x", "b", "y"}, + expected: []string{"y"}, + }, + { + name: "suffix sequence anchors correctly with repeating lines", + prev: []string{"x", "x", "x"}, + next: []string{"x", "x", "x", "x", "x", "new"}, + expected: []string{"new"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, diffLines(tc.prev, tc.next)) + }) + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000000..b7b39a26e2 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package tui provides an interactive terminal dashboard for ToolHive. +package tui + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/workloads" +) + +// activePanel identifies which tab is currently visible in the main area. +type activePanel int + +const ( + panelLogs activePanel = iota + panelInfo + panelTools + panelProxyLogs + panelInspector +) + +// formField bundles a text input with its metadata, replacing parallel slices. +type formField struct { + input textinput.Model + name string + required bool + desc string + typeName string // inspector: type hint like "string", "integer" + secret bool // run form: whether this is a secret field +} + +// inspectorState holds all state for the tool inspector panel. +type inspectorState struct { + toolIdx int + filterActive bool + filterQuery string + fields []formField + fieldIdx int // -1 = no field focused; 0..n-1 = field focused + result string + resultOK bool + resultMs int64 + resultTool string // tool name the current result belongs to + loading bool + showInfo bool // showing tool description modal + spinFrame int + respView viewport.Model + logLines []string + logView viewport.Model + jsonRoot *jsonNode // nil when response is not valid JSON + treeVis []visItem // flattened visible-node list (rebuilt on collapse/expand) + treeCursor int // cursor position in treeVis + treeScroll int // index of first visible item + treeVisH int // available render height (set by resizeViewport) +} + +// runFormState holds state for the "run from registry" form overlay. +type runFormState struct { + open bool + item regtypes.ServerMetadata + fields []formField + idx int + running bool + scroll int +} + +// registryState holds state for the registry browser overlay. +type registryState struct { + open bool + items []regtypes.ServerMetadata + filter string + idx int + scrollOff int // first visible item index in list + loading bool + err error + detail bool // showing detail view for selected item + detailScroll int // scroll offset in detail view +} + +// Model is the top-level BubbleTea model for the TUI dashboard. +type Model struct { + ctx context.Context + manager workloads.Manager + + // Dimensions + width int + height int + + // Sidebar state + workloads []core.Workload + selectedIdx int + filterQuery string + filterActive bool + + // Main panel + panel activePanel + logView viewport.Model + logLines []string + logFollow bool + logHScrollOff int + + // Log search state + logSearchActive bool + logSearchQuery string + logSearchMatches []int // indices into logLines that match + logSearchIdx int // current focused match index + + // Log streaming + logCh <-chan string + logCtxCancel context.CancelFunc + streamingFor string // workload name currently being streamed + + // Tools panel state + tools []vmcp.Tool + toolsLoading bool + toolsFor string // workload name whose tools are loaded + toolsErr error + toolsView viewport.Model + toolsSelectedIdx int // currently highlighted tool in Tools panel + + // Proxy logs panel state + proxyLogView viewport.Model + proxyLogLines []string + proxyLogCh <-chan string + proxyLogCancel context.CancelFunc + proxyLogFor string // workload name currently being streamed for proxy logs + proxyLogHScrollOff int + + // Proxy log search state + proxyLogSearchActive bool + proxyLogSearchQuery string + proxyLogSearchMatches []int + proxyLogSearchIdx int + + // RunConfig (enhanced info panel) + runConfig *runner.RunConfig + runConfigFor string // workload name whose runConfig is loaded + + // Registry overlay state + registry registryState + + // Run-from-registry form state + runForm runFormState + + // Inspector panel state + insp inspectorState + + // TUI-level log capture: slog WARN/ERROR messages sent here while TUI runs. + tuiLogCh <-chan string + + // Transient status bar notification (right-aligned, auto-clears after 3s). + notifMsg string + notifOK bool + + // After a run-from-registry completes, select the new workload by name. + pendingSelect string + + // UI flags + showHelp bool + confirmDelete bool // waiting for second 'd' to confirm deletion + quitting bool +} + +// selected returns the currently selected workload, or nil if none. +func (m *Model) selected() *core.Workload { + list := m.filteredWorkloads() + if len(list) == 0 { + return nil + } + if m.selectedIdx >= len(list) { + return nil + } + w := list[m.selectedIdx] + return &w +} + +// filteredWorkloads returns workloads matching the current filter query. +func (m *Model) filteredWorkloads() []core.Workload { + if !m.filterActive || m.filterQuery == "" { + return m.workloads + } + var out []core.Workload + for _, w := range m.workloads { + if strings.Contains(w.Name, m.filterQuery) { + out = append(out, w) + } + } + return out +} + +// filteredRegistryItems returns registry items matching the current registry filter. +func (m *Model) filteredRegistryItems() []regtypes.ServerMetadata { + return filterRegistryItems(m.registry.items, m.registry.filter) +} + +// filteredTools returns tools matching the current inspector filter query. +func (m *Model) filteredTools() []vmcp.Tool { + if !m.insp.filterActive || m.insp.filterQuery == "" { + return m.tools + } + var out []vmcp.Tool + for _, t := range m.tools { + if strings.Contains(t.Name, m.insp.filterQuery) { + out = append(out, t) + } + } + return out +} diff --git a/pkg/tui/proxylogs.go b/pkg/tui/proxylogs.go new file mode 100644 index 0000000000..483050c94b --- /dev/null +++ b/pkg/tui/proxylogs.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/workloads" +) + +// proxyLogLineMsg carries a single new proxy log line from the polling goroutine. +type proxyLogLineMsg string + +// proxyLogStreamDoneMsg is sent when the proxy log stream channel is closed. +type proxyLogStreamDoneMsg struct{} + +// StreamProxyLogs polls manager.GetProxyLogs for the given workload and sends +// new lines to the returned channel. Cancel ctx to stop. +func StreamProxyLogs(ctx context.Context, manager workloads.Manager, name string) <-chan string { + ch := make(chan string, 256) + go func() { + defer close(ch) + var lastLines []string + for { + select { + case <-ctx.Done(): + return + case <-time.After(logPollInterval): + } + + raw, err := manager.GetProxyLogs(ctx, name, logFetchLines) + if err != nil { + continue + } + + lines := splitLines(raw) + newLines := diffLines(lastLines, lines) + lastLines = lines + + for _, l := range newLines { + select { + case ch <- l: + case <-ctx.Done(): + return + } + } + } + }() + return ch +} + +// readProxyLogLine returns a tea.Cmd that waits for the next proxy log line. +func readProxyLogLine(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return proxyLogStreamDoneMsg{} + } + return proxyLogLineMsg(line) + } +} diff --git a/pkg/tui/registry.go b/pkg/tui/registry.go new file mode 100644 index 0000000000..1fb4e37d36 --- /dev/null +++ b/pkg/tui/registry.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/registry" +) + +// registryLoadedMsg is sent when the registry server list has been fetched. +type registryLoadedMsg struct { + items []regtypes.ServerMetadata + err error +} + +// fetchRegistryItems returns a tea.Cmd that loads all servers from the registry. +func fetchRegistryItems(_ context.Context) tea.Cmd { + return func() tea.Msg { + provider, err := registry.GetDefaultProvider() + if err != nil { + return registryLoadedMsg{err: err} + } + items, err := provider.ListServers() + return registryLoadedMsg{items: items, err: err} + } +} + +// sanitizeRegistryName replaces dots and slashes with dashes for use as a workload name. +func sanitizeRegistryName(name string) string { + r := strings.NewReplacer(".", "-", "/", "-") + return r.Replace(name) +} + +// buildRunFormFields creates form fields from a registry item's metadata. +func buildRunFormFields(item regtypes.ServerMetadata) []formField { + var fields []formField + + // First field: workload name (pre-filled, required). + nameInput := textinput.New() + nameInput.Placeholder = "workload name" + nameInput.SetValue(sanitizeRegistryName(item.GetName())) + nameInput.CharLimit = 64 + fields = append(fields, formField{ + input: nameInput, + name: "name", + required: true, + desc: "Name for the running workload", + }) + + // One field per env var declared by the server. + for _, ev := range item.GetEnvVars() { + if ev == nil { + continue + } + ti := textinput.New() + ti.Placeholder = ev.Name + if ev.Default != "" { + ti.SetValue(ev.Default) + } + if ev.Secret { + ti.EchoMode = textinput.EchoPassword + } + fields = append(fields, formField{ + input: ti, + name: ev.Name, + required: ev.Required, + desc: ev.Description, + secret: ev.Secret, + }) + } + + return fields +} + +// filterRegistryItems returns items whose name or description contains query. +func filterRegistryItems(items []regtypes.ServerMetadata, query string) []regtypes.ServerMetadata { + if query == "" { + return items + } + q := strings.ToLower(query) + var out []regtypes.ServerMetadata + for _, item := range items { + if strings.Contains(strings.ToLower(item.GetName()), q) || + strings.Contains(strings.ToLower(item.GetDescription()), q) { + out = append(out, item) + } + } + return out +} diff --git a/pkg/tui/registry_test.go b/pkg/tui/registry_test.go new file mode 100644 index 0000000000..079cb96778 --- /dev/null +++ b/pkg/tui/registry_test.go @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive-core/permissions" + regtypes "github.com/stacklok/toolhive-core/registry/types" +) + +func TestSanitizeRegistryName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "dots and slashes replaced", + input: "io.github.stacklok/fetch", + expected: "io-github-stacklok-fetch", + }, + { + name: "multiple consecutive dots", + input: "a..b", + expected: "a--b", + }, + { + name: "no special characters", + input: "simple-name", + expected: "simple-name", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "mixed dots slashes and dashes", + input: "io.github/org/tool.v2", + expected: "io-github-org-tool-v2", + }, + { + name: "only dots", + input: "...", + expected: "---", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, sanitizeRegistryName(tc.input)) + }) + } +} + +func TestBuildRunCmd(t *testing.T) { + t.Parallel() + tests := []struct { + name string + item regtypes.ServerMetadata + contains []string + excludes []string + }{ + { + name: "minimal image metadata", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + }, + contains: []string{"thv run 'my-server'"}, + excludes: []string{"--transport", "--permission-profile", "--secret", "--env"}, + }, + { + name: "non-default transport included", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "stdio"}, + }, + contains: []string{"--transport 'stdio'"}, + }, + { + name: "default transport omitted", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "streamable-http"}, + }, + excludes: []string{"--transport"}, + }, + { + name: "permission profile included when non-none", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + Permissions: &permissions.Profile{Name: "network"}, + }, + contains: []string{"--permission-profile 'network'"}, + }, + { + name: "permission profile 'none' omitted", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + Permissions: &permissions.Profile{Name: "none"}, + }, + excludes: []string{"--permission-profile"}, + }, + { + name: "required env var becomes --secret flag", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + EnvVars: []*regtypes.EnvVar{{Name: "API_KEY", Required: true}}, + }, + contains: []string{"--secret 'API_KEY'"}, + }, + { + name: "optional env var becomes comment", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server"}, + EnvVars: []*regtypes.EnvVar{{Name: "LOG_LEVEL", Required: false}}, + }, + contains: []string{"# optional: --env 'LOG_LEVEL'="}, + excludes: []string{"--secret 'LOG_LEVEL'"}, + }, + { + name: "transport and permission profile combined", + item: ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{Name: "my-server", Transport: "sse"}, + Permissions: &permissions.Profile{Name: "network"}, + }, + contains: []string{"--transport 'sse'", "--permission-profile 'network'"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := buildRunCmd(tc.item) + for _, want := range tc.contains { + assert.True(t, strings.Contains(result, want), + "expected %q in output: %q", want, result) + } + for _, unwanted := range tc.excludes { + assert.False(t, strings.Contains(result, unwanted), + "unexpected %q in output: %q", unwanted, result) + } + }) + } +} + +func TestFilterRegistryItems(t *testing.T) { + t.Parallel() + + items := []regtypes.ServerMetadata{ + ®types.RemoteServerMetadata{BaseServerMetadata: regtypes.BaseServerMetadata{Name: "fetch-tool", Description: "Fetches web pages"}}, + ®types.RemoteServerMetadata{BaseServerMetadata: regtypes.BaseServerMetadata{Name: "github-search", Description: "Search GitHub repos"}}, + ®types.RemoteServerMetadata{BaseServerMetadata: regtypes.BaseServerMetadata{Name: "postgres-db", Description: "PostgreSQL database connector"}}, + } + + tests := []struct { + name string + query string + expectedCount int + expectNames []string + }{ + { + name: "empty query returns all", + query: "", + expectedCount: 3, + }, + { + name: "match by name case-insensitive", + query: "FETCH", + expectedCount: 1, + expectNames: []string{"fetch-tool"}, + }, + { + name: "match by description", + query: "github", + expectedCount: 1, + expectNames: []string{"github-search"}, + }, + { + name: "no match returns empty", + query: "nonexistent", + expectedCount: 0, + }, + { + name: "partial match across name and description", + query: "post", + expectedCount: 1, + expectNames: []string{"postgres-db"}, + }, + { + name: "query matches multiple items via description", + query: "search", + expectedCount: 1, + expectNames: []string{"github-search"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := filterRegistryItems(items, tc.query) + assert.Len(t, result, tc.expectedCount) + if tc.expectNames != nil { + for i, name := range tc.expectNames { + assert.Equal(t, name, result[i].GetName()) + } + } + }) + } +} diff --git a/pkg/tui/search_test.go b/pkg/tui/search_test.go new file mode 100644 index 0000000000..798104e0de --- /dev/null +++ b/pkg/tui/search_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestHighlightSubstring(t *testing.T) { + t.Parallel() + + bg := lipgloss.Color("#ffff00") + + tests := []struct { + name string + line string + query string + expectContains []string + expectSame bool // if true, result should equal line exactly + }{ + { + name: "empty query returns original", + line: "hello world", + query: "", + expectSame: true, + }, + { + name: "no match returns line with all original segments", + line: "hello world", + query: "xyz", + expectContains: []string{"hello world"}, + }, + { + name: "case insensitive match wraps with style", + line: "Hello World", + query: "hello", + expectContains: []string{"Hello", "World"}, + }, + { + name: "multiple matches all highlighted", + line: "foo bar foo baz foo", + query: "foo", + expectContains: []string{"foo", "bar", "baz"}, + }, + { + name: "match at end of line", + line: "prefix match", + query: "match", + expectContains: []string{"prefix", "match"}, + }, + { + name: "match at start of line", + line: "start of line", + query: "start", + expectContains: []string{"start", "of line"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lowerQuery := "" + if tc.query != "" { + lq := make([]byte, len(tc.query)) + for i, c := range []byte(tc.query) { + if c >= 'A' && c <= 'Z' { + lq[i] = c + 32 + } else { + lq[i] = c + } + } + lowerQuery = string(lq) + } + result := highlightSubstring(tc.line, tc.query, lowerQuery, bg) + + if tc.expectSame { + assert.Equal(t, tc.line, result) + return + } + for _, substr := range tc.expectContains { + assert.Contains(t, result, substr) + } + }) + } +} diff --git a/pkg/tui/tools.go b/pkg/tui/tools.go new file mode 100644 index 0000000000..9c76a7bcaa --- /dev/null +++ b/pkg/tui/tools.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "errors" + + "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpauthfactory "github.com/stacklok/toolhive/pkg/vmcp/auth/factory" + vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" +) + +// errStdioToolsNotAvailable is returned when tool listing is attempted for a STDIO server. +// STDIO servers only support a single MCP initialize handshake; calling initialize again +// from the TUI would interfere with the real client connection. +var errStdioToolsNotAvailable = errors.New("tool listing not available for STDIO servers") + +// newBackendClientAndTarget creates an authenticated MCP backend client and a +// BackendTarget for the given workload. This is the common setup shared by +// fetchTools and callTool. +func newBackendClientAndTarget(ctx context.Context, workload *core.Workload) (vmcp.BackendClient, *vmcp.BackendTarget, error) { + registry, err := vmcpauthfactory.NewOutgoingAuthRegistry(ctx, &env.OSReader{}) + if err != nil { + return nil, nil, err + } + + mcpClient, err := vmcpclient.NewHTTPBackendClient(registry) + if err != nil { + return nil, nil, err + } + + // For stdio workloads the proxy exposes an HTTP transport (sse or streamable-http). + // ProxyMode holds the actual transport type clients should use. + transportType := workload.ProxyMode + if transportType == "" { + transportType = string(workload.TransportType) + } + + target := &vmcp.BackendTarget{ + WorkloadID: workload.Name, + WorkloadName: workload.Name, + BaseURL: workload.URL, + TransportType: transportType, + } + + return mcpClient, target, nil +} + +// fetchTools connects to the running MCP server and returns its tool list. +func fetchTools(ctx context.Context, workload *core.Workload) ([]vmcp.Tool, error) { + mcpClient, target, err := newBackendClientAndTarget(ctx, workload) + if err != nil { + return nil, err + } + + caps, err := mcpClient.ListCapabilities(ctx, target) + if err != nil { + return nil, err + } + + return caps.Tools, nil +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000000..139436699f --- /dev/null +++ b/pkg/tui/update.go @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// workloadsRefreshMsg is sent when the workload list is refreshed. +type workloadsRefreshMsg struct { + workloads []core.Workload +} + +// notifClearMsg is sent after the notification auto-dismiss timer fires. +type notifClearMsg struct{} + +// showNotif sets a transient notification and schedules its auto-clear after 3s. +func (m *Model) showNotif(msg string, ok bool) tea.Cmd { + m.notifMsg = msg + m.notifOK = ok + return tea.Tick(3*time.Second, func(time.Time) tea.Msg { return notifClearMsg{} }) +} + +// tuiLogMsg carries a captured slog message for display in the inspector. +type tuiLogMsg string + +// logLineMsg carries a single new log line from the streaming goroutine. +type logLineMsg string + +// logStreamDoneMsg is sent when the log stream channel is closed. +type logStreamDoneMsg struct{} + +// tickMsg is sent by the periodic workload refresh ticker. +type tickMsg time.Time + +// toolsFetchedMsg is sent when the tools list is loaded from an MCP server. +type toolsFetchedMsg struct { + workloadName string + tools []vmcp.Tool + err error +} + +// runConfigLoadedMsg is sent when the RunConfig is loaded for a workload. +type runConfigLoadedMsg struct { + workloadName string + cfg *runner.RunConfig + err error +} + +const refreshInterval = 5 * time.Second + +// maxLogLines caps the in-memory log buffer to prevent unbounded growth during +// long-running sessions. When exceeded, the oldest lines are dropped. +const maxLogLines = 10_000 + +// Init starts background ticks for workload refresh. +func (m Model) Init() tea.Cmd { + return tea.Batch( + scheduleRefresh(), + m.startLogStream(), + m.watchTUILog(), + ) +} + +// watchTUILog returns a command that waits for the next slog message on tuiLogCh. +func (m *Model) watchTUILog() tea.Cmd { + if m.tuiLogCh == nil { + return nil + } + ch := m.tuiLogCh + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + return tuiLogMsg(msg) + } +} + +func scheduleRefresh() tea.Cmd { + return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// startLogStream begins streaming logs for the currently selected workload. +func (m *Model) startLogStream() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + + // Cancel any existing stream. + if m.logCtxCancel != nil { + m.logCtxCancel() + } + m.logLines = nil + m.logView.SetContent("") + + ctx, cancel := context.WithCancel(m.ctx) + m.logCtxCancel = cancel + m.streamingFor = sel.Name + m.logCh = StreamWorkloadLogs(ctx, m.manager, sel.Name) + + return readLogLine(m.logCh) +} + +// readLogLine returns a tea.Cmd that waits for the next log line. +func readLogLine(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logStreamDoneMsg{} + } + return logLineMsg(line) + } +} + +// startProxyLogStream begins streaming proxy logs for the currently selected workload. +func (m *Model) startProxyLogStream() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + + if m.proxyLogCancel != nil { + m.proxyLogCancel() + } + m.proxyLogLines = nil + m.proxyLogView.SetContent("") + + ctx, cancel := context.WithCancel(m.ctx) + m.proxyLogCancel = cancel + m.proxyLogFor = sel.Name + m.proxyLogCh = StreamProxyLogs(ctx, m.manager, sel.Name) + + return readProxyLogLine(m.proxyLogCh) +} + +// Update handles all incoming messages and key events. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if cmd, done := m.handleMsg(msg); done { + return m, cmd + } else if cmd != nil { + cmds = append(cmds, cmd) + } + + // Forward scroll events to the active viewport. + switch m.panel { + case panelLogs: + var vpCmd tea.Cmd + m.logView, vpCmd = m.logView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelProxyLogs: + var vpCmd tea.Cmd + m.proxyLogView, vpCmd = m.proxyLogView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelInspector: + var vpCmd tea.Cmd + m.insp.respView, vpCmd = m.insp.respView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelTools: + var vpCmd tea.Cmd + m.toolsView, vpCmd = m.toolsView.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + case panelInfo: + // no viewport to forward scroll to + } + + return m, tea.Batch(cmds...) +} + +// handleMsg dispatches a message and returns (cmd, earlyReturn). +// earlyReturn=true means Update should return immediately with cmd. +// +//nolint:gocyclo // dispatches over all message types; splitting would add indirection without clarity +func (m *Model) handleMsg(msg tea.Msg) (tea.Cmd, bool) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeViewport() + return nil, true + case tea.KeyMsg: + return m.handleKey(msg), false + case tickMsg: + return tea.Batch(m.refreshWorkloads(), scheduleRefresh()), false + case workloadsRefreshMsg: + return m.handleWorkloadsRefresh(msg) + case logLineMsg: + return m.handleLogLine(msg) + case logStreamDoneMsg: + // Stream ended; do nothing — a future selection change will restart it. + case proxyLogLineMsg: + return m.handleProxyLogLine(msg) + case proxyLogStreamDoneMsg: + // Stream ended. + case actionDoneMsg: + var notifCmd tea.Cmd + if msg.err != nil { + notifCmd = m.showNotif("✗ "+msg.name+": "+msg.err.Error(), false) + } else { + notifCmd = m.showNotif("✓ "+msg.name+" "+msg.action, true) + } + return tea.Batch(m.refreshWorkloads(), notifCmd), false + case runFormResultMsg: + m.runForm.running = false + m.runForm.open = false + m.registry.detail = false + m.registry.open = false + var notifCmd tea.Cmd + if msg.err != nil { + notifCmd = m.showNotif("✗ "+msg.server+": "+msg.err.Error(), false) + } else { + m.pendingSelect = msg.name + notifCmd = m.showNotif("✓ "+msg.name+" started", true) + } + return tea.Batch(m.refreshWorkloads(), notifCmd), false + case notifClearMsg: + m.notifMsg = "" + return nil, false + case toolsFetchedMsg: + m.handleToolsFetched(msg) + case registryLoadedMsg: + m.handleRegistryLoaded(msg) + case runConfigLoadedMsg: + m.handleRunConfigLoaded(msg) + case inspCallResultMsg: + m.handleInspCallResult(msg) + case tuiLogMsg: + return m.handleTUILog(msg), false + case inspSpinTickMsg: + if m.insp.loading { + m.insp.spinFrame = (m.insp.spinFrame + 1) % len(inspSpinFrames) + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return inspSpinTickMsg{} }), false + } + } + return nil, false +} + +func (m *Model) handleWorkloadsRefresh(msg workloadsRefreshMsg) (tea.Cmd, bool) { + core.SortWorkloadsByName(msg.workloads) + m.workloads = msg.workloads + filtered := m.filteredWorkloads() + if m.pendingSelect != "" { + for i, w := range filtered { + if w.Name == m.pendingSelect { + m.selectedIdx = i + m.pendingSelect = "" + return nil, false + } + } + } + if m.selectedIdx >= len(filtered) && m.selectedIdx > 0 { + m.selectedIdx = len(filtered) - 1 + } + return nil, false +} + +func (m *Model) handleLogLine(msg logLineMsg) (tea.Cmd, bool) { + m.logLines = append(m.logLines, formatLogLine(string(msg))) + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + if m.logFollow { + m.logView.GotoBottom() + } + if m.logCh != nil { + return readLogLine(m.logCh), false + } + return nil, false +} + +func (m *Model) handleProxyLogLine(msg proxyLogLineMsg) (tea.Cmd, bool) { + m.proxyLogLines = append(m.proxyLogLines, formatLogLine(string(msg))) + if len(m.proxyLogLines) > maxLogLines { + m.proxyLogLines = m.proxyLogLines[len(m.proxyLogLines)-maxLogLines:] + } + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.GotoBottom() + if m.proxyLogCh != nil { + return readProxyLogLine(m.proxyLogCh), false + } + return nil, false +} + +func (m *Model) handleToolsFetched(msg toolsFetchedMsg) { + if msg.workloadName == m.toolsFor { + m.tools = msg.tools + m.toolsErr = msg.err + m.toolsLoading = false + m.toolsSelectedIdx = 0 + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsView.GotoTop() + } +} + +func (m *Model) handleRegistryLoaded(msg registryLoadedMsg) { + m.registry.loading = false + m.registry.err = msg.err + m.registry.items = msg.items + m.registry.idx = 0 +} + +func (m *Model) handleRunConfigLoaded(msg runConfigLoadedMsg) { + if msg.workloadName == m.runConfigFor { + m.runConfig = msg.cfg + } +} + +// maxTUILogLines caps the inspector slog buffer to prevent unbounded growth. +const maxTUILogLines = 500 + +// handleTUILog appends a captured slog message to the inspector log view. +func (m *Model) handleTUILog(msg tuiLogMsg) tea.Cmd { + m.insp.logLines = append(m.insp.logLines, string(msg)) + if len(m.insp.logLines) > maxTUILogLines { + m.insp.logLines = m.insp.logLines[len(m.insp.logLines)-maxTUILogLines:] + } + content := strings.Join(m.insp.logLines, "\n") + m.insp.logView.SetContent(content) + m.insp.logView.GotoBottom() + return m.watchTUILog() +} + +// handleInspCallResult processes the result of a tool call from the inspector. +func (m *Model) handleInspCallResult(msg inspCallResultMsg) { + m.insp.loading = false + m.insp.resultMs = msg.elapsedMs + if msg.err != nil { + m.insp.result = "Error: " + msg.err.Error() + m.insp.resultOK = false + } else { + m.insp.result = formatInspResult(msg.result) + m.insp.resultOK = msg.result == nil || !msg.result.IsError + } + m.insp.respView.SetContent(m.insp.result) + m.insp.respView.GotoTop() + + // Attempt to parse the result as a JSON tree for interactive display. + m.insp.jsonRoot = parseJSONTree(m.insp.result) + if m.insp.jsonRoot != nil { + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + m.insp.treeCursor = 0 + m.insp.treeScroll = 0 + } +} + +// handleKey dispatches key events and returns a follow-up tea.Cmd if any. +func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { + // Registry overlay has its own key handling. + if m.registry.open { + return m.handleRegistryKey(msg) + } + if m.filterActive { + return m.handleFilterKey(msg) + } + if m.showHelp { + m.showHelp = false + return nil + } + if m.confirmDelete { + return m.handleConfirmDeleteKey(msg) + } + if m.panel == panelInspector { + return m.handleInspectorKey(msg) + } + // Log search prompt captures all input while active. + if m.panel == panelLogs && m.logSearchActive { + return m.handleLogSearchKey(msg) + } + if m.panel == panelProxyLogs && m.proxyLogSearchActive { + return m.handleProxyLogSearchKey(msg) + } + return m.handleNormalKey(msg) +} diff --git a/pkg/tui/update_inspector.go b/pkg/tui/update_inspector.go new file mode 100644 index 0000000000..d1b2d56f4e --- /dev/null +++ b/pkg/tui/update_inspector.go @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// handleInspectorKey handles key input when the inspector panel is active. +// +//nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all inspector key bindings +func (m *Model) handleInspectorKey(msg tea.KeyMsg) tea.Cmd { + // Info modal captures all input — any key closes it. + if m.insp.showInfo { + m.insp.showInfo = false + return nil + } + + // Filter prompt captures all input until Enter or Esc. + if m.insp.filterActive { + return m.handleInspFilterKey(msg) + } + + // When a text field is focused, forward everything except Tab/ShiftTab/Escape/Enter/arrows. + if m.insp.fieldIdx >= 0 { + switch { + case key.Matches(msg, keys.Escape): + m.blurAllInspFields() + return nil + case key.Matches(msg, keys.Tab): + return m.inspNextField() + case key.Matches(msg, keys.ShiftTab): + return m.inspPrevField() + case key.Matches(msg, keys.Enter): + // Enter calls the tool even while a field is focused. + return m.inspDoCall() + case key.Matches(msg, keys.Up): + // Arrow keys move the JSON tree cursor; single-line textinputs ignore them anyway. + if m.insp.jsonRoot != nil { + return m.inspTreeMove(-1) + } + if m.insp.result != "" { + m.insp.respView.ScrollUp(1) + return nil + } + return m.inspForwardToField(msg) + case key.Matches(msg, keys.Down): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(1) + } + if m.insp.result != "" { + m.insp.respView.ScrollDown(1) + return nil + } + return m.inspForwardToField(msg) + case key.Matches(msg, keys.Space): + // Intercept Space for JSON tree collapse even when a field is focused. + if m.insp.jsonRoot != nil { + toggleCollapse(m.insp.treeVis, m.insp.treeCursor) + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + return nil + } + return m.inspForwardToField(msg) + default: + return m.inspForwardToField(msg) + } + } + + // No field focused — navigation mode. + switch { + case key.Matches(msg, keys.Escape): + // Esc goes back to the tools panel; response is preserved until + // the user changes tool or leaves the inspector panel. + m.panel = panelTools + m.blurAllInspFields() + return nil + case key.Matches(msg, keys.Up): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(-1) + } + if m.insp.result != "" { + m.insp.respView.ScrollUp(1) + return nil + } + return m.inspNavigateUp() + case key.Matches(msg, keys.Down): + if m.insp.jsonRoot != nil { + return m.inspTreeMove(1) + } + if m.insp.result != "" { + m.insp.respView.ScrollDown(1) + return nil + } + return m.inspNavigateDown() + case key.Matches(msg, keys.Space): + // Toggle collapse on the selected JSON node. + if m.insp.jsonRoot != nil { + toggleCollapse(m.insp.treeVis, m.insp.treeCursor) + m.insp.treeVis = flattenVisible(m.insp.jsonRoot) + // Clamp cursor in case collapsed nodes removed items below cursor. + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + } + return nil + case key.Matches(msg, keys.CopyCurl): + // y copies the curl command for the current tool call to clipboard. + if sel := m.selected(); sel != nil { + if ft := m.filteredTools(); len(ft) > 0 && m.insp.toolIdx < len(ft) { + tool := ft[m.insp.toolIdx] + curl := buildCurlStr(sel, tool.Name, inspFieldValues(m.insp.fields)) + _ = clipboard.WriteAll(curl) + return m.showNotif("✓ curl copied", true) + } + } + return nil + case key.Matches(msg, keys.CopyNode): + // c copies the full response JSON to clipboard. + if m.insp.result != "" { + m.inspCopyNode() + return m.showNotif("✓ copied to clipboard", true) + } + return nil + case key.Matches(msg, keys.Filter): + // / opens the tool filter prompt. + m.insp.filterActive = true + m.insp.filterQuery = "" + m.insp.toolIdx = 0 + m.inspRebuildForm() + return nil + case key.Matches(msg, keys.ToolInfo): + // i opens the tool description modal. + if ft := m.filteredTools(); len(ft) > 0 && m.insp.toolIdx < len(ft) { + m.insp.showInfo = true + } + return nil + case key.Matches(msg, keys.Tab): + return m.togglePanel() + case key.Matches(msg, keys.Enter): + if len(m.insp.fields) > 0 { + return m.inspNextField() + } + return m.inspDoCall() + default: + return nil + } +} + +// handleInspFilterKey handles key input while the inspector tool filter is active. +func (m *Model) handleInspFilterKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + m.insp.filterActive = false + m.insp.filterQuery = "" + m.insp.toolIdx = 0 + m.inspRebuildForm() + case key.Matches(msg, keys.Enter): + // Find the currently selected tool in the filtered list, then clear the + // filter so the full list is shown with that tool still highlighted. + filtered := m.filteredTools() + var selectedTool *vmcp.Tool + if len(filtered) > 0 && m.insp.toolIdx < len(filtered) { + t := filtered[m.insp.toolIdx] + selectedTool = &t + } + m.insp.filterActive = false + m.insp.filterQuery = "" + // Restore toolIdx to the tool's position in the full list. + if selectedTool != nil { + for i, t := range m.tools { + if t.Name == selectedTool.Name { + m.insp.toolIdx = i + break + } + } + } + if len(m.insp.fields) > 0 { + m.insp.fieldIdx = 0 + m.insp.fields[0].input.Focus() + } + case key.Matches(msg, keys.Up): + return m.inspNavigateUp() + case key.Matches(msg, keys.Down): + return m.inspNavigateDown() + case msg.Type == tea.KeyBackspace: + if len(m.insp.filterQuery) > 0 { + r := []rune(m.insp.filterQuery) + m.insp.filterQuery = string(r[:len(r)-1]) + m.insp.toolIdx = 0 + m.inspRebuildForm() + } + default: + if msg.Type == tea.KeyRunes { + m.insp.filterQuery += msg.String() + m.insp.toolIdx = 0 + m.inspRebuildForm() + } + } + return nil +} + +// inspNavigateUp moves to the previous tool in the filtered list. +func (m *Model) inspNavigateUp() tea.Cmd { + if m.insp.toolIdx > 0 { + m.insp.toolIdx-- + m.inspRebuildForm() + } + return nil +} + +// inspNavigateDown moves to the next tool in the filtered list. +func (m *Model) inspNavigateDown() tea.Cmd { + if m.insp.toolIdx < len(m.filteredTools())-1 { + m.insp.toolIdx++ + m.inspRebuildForm() + } + return nil +} + +// inspNextField advances focus to the next inspector form field. +func (m *Model) inspNextField() tea.Cmd { + formNextField(m.insp.fields, &m.insp.fieldIdx) + return nil +} + +// inspPrevField moves focus to the previous inspector form field. +func (m *Model) inspPrevField() tea.Cmd { + formPrevField(m.insp.fields, &m.insp.fieldIdx) + return nil +} + +// blurAllInspFields blurs all inspector text inputs and resets the focused index. +func (m *Model) blurAllInspFields() { + formBlurAll(m.insp.fields, &m.insp.fieldIdx) +} + +// inspRebuildForm rebuilds the form fields for the currently selected tool. +func (m *Model) inspRebuildForm() { + filtered := m.filteredTools() + if len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + m.insp.fields = nil + m.insp.fieldIdx = -1 + m.insp.result = "" + m.insp.resultOK = false + m.insp.resultMs = 0 + m.insp.respView.SetContent("") + m.insp.logLines = nil + m.insp.logView.SetContent("") + return + } + tool := filtered[m.insp.toolIdx] + // Preserve the result if we're rebuilding for the same tool (e.g. re-entering inspector). + if m.insp.resultTool != tool.Name { + m.insp.result = "" + m.insp.resultOK = false + m.insp.resultMs = 0 + m.insp.respView.SetContent("") + m.insp.logLines = nil + m.insp.logView.SetContent("") + m.insp.jsonRoot = nil + m.insp.treeVis = nil + m.insp.treeCursor = 0 + m.insp.treeScroll = 0 + } + m.insp.fields = buildInspFields(tool) + m.insp.fieldIdx = -1 +} + +// inspTreeMove moves the JSON tree cursor by delta (+1 down, -1 up) and adjusts scroll. +func (m *Model) inspTreeMove(delta int) tea.Cmd { + if len(m.insp.treeVis) == 0 { + return nil + } + m.insp.treeCursor += delta + if m.insp.treeCursor < 0 { + m.insp.treeCursor = 0 + } + if m.insp.treeCursor >= len(m.insp.treeVis) { + m.insp.treeCursor = len(m.insp.treeVis) - 1 + } + m.treeClampScroll() + return nil +} + +// treeClampScroll adjusts treeScroll so that treeCursor stays in the visible window. +func (m *Model) treeClampScroll() { + if m.insp.treeVisH <= 0 { + return + } + if m.insp.treeCursor < m.insp.treeScroll { + m.insp.treeScroll = m.insp.treeCursor + } + if m.insp.treeCursor >= m.insp.treeScroll+m.insp.treeVisH { + m.insp.treeScroll = m.insp.treeCursor - m.insp.treeVisH + 1 + } +} + +// inspCopyNode copies the full response JSON to the clipboard. +func (m *Model) inspCopyNode() { + if m.insp.result == "" { + return + } + _ = clipboard.WriteAll(m.insp.result) +} + +// inspForwardToField forwards a key message to the currently focused field. +func (m *Model) inspForwardToField(msg tea.KeyMsg) tea.Cmd { + return formForwardKey(m.insp.fields, m.insp.fieldIdx, msg) +} + +// inspDoCall starts an async tool call with the current field values. +func (m *Model) inspDoCall() tea.Cmd { + if m.insp.loading { + return nil + } + sel := m.selected() + filtered := m.filteredTools() + if sel == nil || len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + return nil + } + tool := filtered[m.insp.toolIdx] + args := inspFieldValues(m.insp.fields) + m.blurAllInspFields() + m.insp.loading = true + m.insp.spinFrame = 0 + m.insp.resultTool = tool.Name // track which tool the result belongs to + spinCmd := tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return inspSpinTickMsg{} }) + callCmd := startInspCallTool(m.ctx, sel, tool.Name, args) + return tea.Batch(spinCmd, callCmd) +} diff --git a/pkg/tui/update_navigation.go b/pkg/tui/update_navigation.go new file mode 100644 index 0000000000..8186d34eb4 --- /dev/null +++ b/pkg/tui/update_navigation.go @@ -0,0 +1,504 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" + types "github.com/stacklok/toolhive/pkg/transport/types" +) + +// handleConfirmDeleteKey handles key input while waiting for delete confirmation. +func (m *Model) handleConfirmDeleteKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Delete): + m.confirmDelete = false + return m.doDelete() + default: + m.confirmDelete = false + } + return nil +} + +// handleFilterKey handles key input while the filter prompt is active. +func (m *Model) handleFilterKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape) || key.Matches(msg, keys.Quit): + m.filterActive = false + m.filterQuery = "" + m.selectedIdx = 0 + case key.Matches(msg, keys.Enter): + m.filterActive = false + case msg.Type == tea.KeyBackspace: + if len(m.filterQuery) > 0 { + r := []rune(m.filterQuery) + m.filterQuery = string(r[:len(r)-1]) + } + default: + if msg.Type == tea.KeyRunes { + m.filterQuery += msg.String() + } + } + return nil +} + +// handleNormalKey handles key input in normal (non-filter) mode. +// +//nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all normal-mode key bindings +func (m *Model) handleNormalKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Quit): + m.quitting = true + return tea.Quit + + case key.Matches(msg, keys.Up): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsNavigateUp() + } + return m.navigateUp() + + case key.Matches(msg, keys.Down): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsNavigateDown() + } + return m.navigateDown() + + case key.Matches(msg, keys.Enter): + if m.panel == panelTools && len(m.tools) > 0 { + return m.toolsJumpToInspector() + } + + case key.Matches(msg, keys.Tab): + return m.togglePanel() + + case key.Matches(msg, keys.Follow): + m.toggleFollow() + + case key.Matches(msg, keys.Stop): + return m.doStop() + + case key.Matches(msg, keys.Restart): + return m.doRestart() + + case key.Matches(msg, keys.Delete): + if sel := m.selected(); sel != nil { + m.confirmDelete = true + } + + case key.Matches(msg, keys.Filter): + if m.panel == panelLogs { + m.logSearchActive = true + return nil + } + if m.panel == panelProxyLogs { + m.proxyLogSearchActive = true + return nil + } + m.filterActive = true + m.filterQuery = "" + + case key.Matches(msg, keys.Help): + m.showHelp = true + + case key.Matches(msg, keys.Registry): + return m.openRegistry() + + case key.Matches(msg, keys.Escape): + if m.panel == panelLogs && m.logSearchQuery != "" { + m.logSearchQuery = "" + m.logSearchMatches = nil + m.logSearchIdx = 0 + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + if m.panel == panelProxyLogs && m.proxyLogSearchQuery != "" { + m.proxyLogSearchQuery = "" + m.proxyLogSearchMatches = nil + m.proxyLogSearchIdx = 0 + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + + case key.Matches(msg, keys.SearchNext): + if m.panel == panelLogs && len(m.logSearchMatches) > 0 { + m.logSearchIdx = (m.logSearchIdx + 1) % len(m.logSearchMatches) + m.scrollToMatch() + } + if m.panel == panelProxyLogs && len(m.proxyLogSearchMatches) > 0 { + m.proxyLogSearchIdx = (m.proxyLogSearchIdx + 1) % len(m.proxyLogSearchMatches) + m.scrollToProxyMatch() + } + + case key.Matches(msg, keys.SearchPrev): + if m.panel == panelLogs && len(m.logSearchMatches) > 0 { + m.logSearchIdx = (m.logSearchIdx - 1 + len(m.logSearchMatches)) % len(m.logSearchMatches) + m.scrollToMatch() + } + if m.panel == panelProxyLogs && len(m.proxyLogSearchMatches) > 0 { + m.proxyLogSearchIdx = (m.proxyLogSearchIdx - 1 + len(m.proxyLogSearchMatches)) % len(m.proxyLogSearchMatches) + m.scrollToProxyMatch() + } + + case key.Matches(msg, keys.ScrollLeft): + m.hScrollLeft() + + case key.Matches(msg, keys.ScrollRight): + m.hScrollRight() + + case key.Matches(msg, keys.CopyURL): + if sel := m.selected(); sel != nil && sel.URL != "" { + _ = clipboard.WriteAll(sel.URL) + return m.showNotif("✓ URL copied", true) + } + } + + return nil +} + +// toolsNavigateUp moves the tool selection up and refreshes the viewport. +func (m *Model) toolsNavigateUp() tea.Cmd { + if m.toolsSelectedIdx > 0 { + m.toolsSelectedIdx-- + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsScrollToSelected() + } + return nil +} + +// toolsNavigateDown moves the tool selection down and refreshes the viewport. +func (m *Model) toolsNavigateDown() tea.Cmd { + if m.toolsSelectedIdx < len(m.tools)-1 { + m.toolsSelectedIdx++ + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsScrollToSelected() + } + return nil +} + +// toolsScrollToSelected adjusts the viewport so the selected tool stays visible. +func (m *Model) toolsScrollToSelected() { + // Each tool occupies approximately 1-3 lines; use a rough line-per-tool estimate. + // The header is 2 lines (count + blank line). + const headerLines = 2 + line := headerLines + m.toolsSelectedIdx + if line < m.toolsView.YOffset { + m.toolsView.SetYOffset(line) + } else if line >= m.toolsView.YOffset+m.toolsView.Height { + m.toolsView.SetYOffset(line - m.toolsView.Height + 1) + } +} + +// toolsJumpToInspector switches to the Inspector panel with the currently +// selected tool pre-selected and the form ready to fill. +func (m *Model) toolsJumpToInspector() tea.Cmd { + // Find the matching index in the inspector tool list (same m.tools slice). + m.insp.toolIdx = m.toolsSelectedIdx + if m.insp.toolIdx >= len(m.tools) { + m.insp.toolIdx = 0 + } + m.panel = panelInspector + m.inspRebuildForm() + // Focus the first field if available. + if len(m.insp.fields) > 0 { + m.insp.fieldIdx = 0 + m.insp.fields[0].input.Focus() + } + return m.maybeStartToolsFetch() +} + +func (m *Model) navigateUp() tea.Cmd { + if m.selectedIdx > 0 { + m.selectedIdx-- + return m.onSelectionChanged() + } + return nil +} + +func (m *Model) navigateDown() tea.Cmd { + list := m.filteredWorkloads() + if m.selectedIdx < len(list)-1 { + m.selectedIdx++ + return m.onSelectionChanged() + } + return nil +} + +// hScrollLeft scrolls the active log panel left by 8 columns. +func (m *Model) hScrollLeft() { + const step = 8 + switch m.panel { + case panelLogs: + if m.logHScrollOff > 0 { + m.logHScrollOff -= step + if m.logHScrollOff < 0 { + m.logHScrollOff = 0 + } + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + case panelProxyLogs: + if m.proxyLogHScrollOff > 0 { + m.proxyLogHScrollOff -= step + if m.proxyLogHScrollOff < 0 { + m.proxyLogHScrollOff = 0 + } + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + case panelInfo, panelTools, panelInspector: + // h-scroll not applicable to these panels + } +} + +// hScrollRight scrolls the active log panel right by 8 columns. +func (m *Model) hScrollRight() { + const step = 8 + switch m.panel { + case panelLogs: + maxOff := maxLineLen(m.logLines) + if m.logHScrollOff+step <= maxOff { + m.logHScrollOff += step + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + } + case panelProxyLogs: + maxOff := maxLineLen(m.proxyLogLines) + if m.proxyLogHScrollOff+step <= maxOff { + m.proxyLogHScrollOff += step + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + } + case panelInfo, panelTools, panelInspector: + // h-scroll not applicable to these panels + } +} + +// maxLineLen returns the length (in runes) of the longest line in the slice. +func maxLineLen(lines []string) int { + m := 0 + for _, l := range lines { + if n := len([]rune(l)); n > m { + m = n + } + } + return m +} + +// onSelectionChanged resets panel state and starts any needed background fetches. +func (m *Model) onSelectionChanged() tea.Cmd { + m.toolsFor = "" // invalidate tools cache + m.toolsSelectedIdx = 0 // reset tool selection + m.runConfigFor = "" // invalidate runConfig cache + m.runConfig = nil + m.logHScrollOff = 0 + m.proxyLogHScrollOff = 0 + + // Reset inspector state on selection change. + m.insp.toolIdx = 0 + m.insp.fields = nil + m.insp.fieldIdx = -1 + m.insp.result = "" + + // Cancel proxy log stream for old selection. + if m.proxyLogCancel != nil { + m.proxyLogCancel() + m.proxyLogCancel = nil + m.proxyLogLines = nil + m.proxyLogView.SetContent("") + m.proxyLogFor = "" + } + + cmds := []tea.Cmd{m.startLogStream()} + switch m.panel { + case panelTools: + cmds = append(cmds, m.maybeStartToolsFetch()) + case panelInfo: + cmds = append(cmds, m.maybeLoadRunConfig()) + case panelProxyLogs: + cmds = append(cmds, m.startProxyLogStream()) + case panelInspector: + cmds = append(cmds, m.maybeStartToolsFetch()) + case panelLogs: + // log stream already started above + } + return tea.Batch(cmds...) +} + +func (m *Model) togglePanel() tea.Cmd { + switch m.panel { + case panelLogs: + m.panel = panelInfo + return m.maybeLoadRunConfig() + case panelInfo: + m.panel = panelTools + return m.maybeStartToolsFetch() + case panelTools: + m.panel = panelProxyLogs + return m.startProxyLogStream() + case panelProxyLogs: + // Stop proxy log stream when leaving the panel. + if m.proxyLogCancel != nil { + m.proxyLogCancel() + m.proxyLogCancel = nil + } + m.panel = panelInspector + m.inspRebuildForm() + return m.maybeStartToolsFetch() + case panelInspector: + m.blurAllInspFields() + m.panel = panelLogs + } + return nil +} + +// maybeStartToolsFetch fetches tools for the selected workload if not already loaded. +func (m *Model) maybeStartToolsFetch() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + // STDIO servers only support a single initialize handshake; calling it again + // from the TUI would interfere with the real client connection. + if sel.TransportType == types.TransportTypeStdio { + m.toolsFor = sel.Name + m.toolsLoading = false + m.tools = nil + m.toolsErr = errStdioToolsNotAvailable + return nil + } + // Retry if previously failed; skip only when successfully loaded. + if m.toolsFor == sel.Name && !m.toolsLoading && m.toolsErr == nil { + return nil // already loaded successfully + } + m.toolsFor = sel.Name + m.toolsLoading = true + m.tools = nil + m.toolsErr = nil + return startToolsFetch(m.ctx, sel) +} + +// maybeLoadRunConfig loads the RunConfig for the selected workload if not already loaded. +func (m *Model) maybeLoadRunConfig() tea.Cmd { + sel := m.selected() + if sel == nil { + return nil + } + if m.runConfigFor == sel.Name && m.runConfig != nil { + return nil // already loaded + } + m.runConfigFor = sel.Name + m.runConfig = nil + name := sel.Name + ctx := m.ctx + return func() tea.Msg { + cfg, err := runner.LoadState(ctx, name) + if err != nil { + return runConfigLoadedMsg{workloadName: name, cfg: nil, err: err} + } + return runConfigLoadedMsg{workloadName: name, cfg: cfg} + } +} + +// startToolsFetch returns a tea.Cmd that fetches tools for a workload. +func startToolsFetch(ctx context.Context, w *core.Workload) tea.Cmd { + name := w.Name + wCopy := *w + return func() tea.Msg { + tools, err := fetchTools(ctx, &wCopy) + return toolsFetchedMsg{workloadName: name, tools: tools, err: err} + } +} + +func (m *Model) toggleFollow() { + m.logFollow = !m.logFollow + if m.logFollow { + m.logView.GotoBottom() + } +} + +func (m *Model) doStop() tea.Cmd { + if sel := m.selected(); sel != nil { + return stopWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +func (m *Model) doRestart() tea.Cmd { + if sel := m.selected(); sel != nil { + return restartWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +func (m *Model) doDelete() tea.Cmd { + if sel := m.selected(); sel != nil { + return deleteWorkload(m.ctx, m.manager, sel.Name) + } + return nil +} + +// openRegistry opens the registry overlay and triggers a fetch if needed. +func (m *Model) openRegistry() tea.Cmd { + m.registry.open = true + m.registry.filter = "" + m.registry.idx = 0 + if len(m.registry.items) > 0 { + return nil // already loaded + } + m.registry.loading = true + m.registry.err = nil + return fetchRegistryItems(m.ctx) +} + +// refreshWorkloads returns a tea.Cmd that fetches the workload list. +func (m *Model) refreshWorkloads() tea.Cmd { + return func() tea.Msg { + list, err := m.manager.ListWorkloads(m.ctx, true) + if err != nil { + return nil + } + return workloadsRefreshMsg{workloads: list} + } +} + +// resizeViewport recalculates the viewport dimensions based on the terminal size. +func (m *Model) resizeViewport() { + sidebarWidth := sidebarW(m.width) + mainWidth := m.width - sidebarWidth - 1 // 1 for the divider + // mainStyle Height = m.height-2; title(1)+tabBar(1)+sep(1)+toolbar(1) = 4 overhead + logHeight := max(m.height-6, 1) + m.logView.Width = mainWidth + m.logView.Height = logHeight + m.proxyLogView.Width = mainWidth + m.proxyLogView.Height = logHeight + // Tools viewport: same height as logs, rebuild content to reflect new width. + if m.toolsView.Width != mainWidth || m.toolsView.Height != logHeight { + m.toolsView.Width = mainWidth + m.toolsView.Height = logHeight + if len(m.tools) > 0 { + m.toolsView.SetContent(buildToolsContent(m.tools, mainWidth, m.toolsSelectedIdx)) + } + } + // Inspector response viewport: right-column minus headers (~8 lines) and log section (6 lines). + const inspLogHeight = 6 + // inspH = m.height - 5 (from renderInspector); 8 lines of REQUEST/RESPONSE headers overhead. + const inspHeaderOverhead = 8 + m.insp.logView.Width = mainWidth + m.insp.logView.Height = inspLogHeight + m.insp.respView.Width = mainWidth + m.insp.respView.Height = max(m.height-10-inspLogHeight, 3) + m.insp.treeVisH = max(m.height-5-inspHeaderOverhead, 3) +} + +// sidebarW returns the sidebar width given total terminal width. +func sidebarW(totalWidth int) int { + w := totalWidth / 4 + if w < 24 { + return 24 + } + if w > 40 { + return 40 + } + return w +} diff --git a/pkg/tui/update_registry.go b/pkg/tui/update_registry.go new file mode 100644 index 0000000000..3f1c64fe9c --- /dev/null +++ b/pkg/tui/update_registry.go @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + regtypes "github.com/stacklok/toolhive-core/registry/types" +) + +// handleRegistryKey handles key input while the registry overlay is open. +func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { + // Run form captures all input while open. + if m.runForm.open { + return m.handleRunFormKey(msg) + } + // Detail view has its own key handling. + if m.registry.detail { + return m.handleRegistryDetailKey(msg) + } + switch { + case key.Matches(msg, keys.Escape), key.Matches(msg, keys.Registry): + m.registry.open = false + m.registry.detail = false + m.registry.filter = "" + m.registry.idx = 0 + m.registry.scrollOff = 0 + case key.Matches(msg, keys.Enter): + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + m.registry.detail = true + m.registry.detailScroll = 0 + } + case key.Matches(msg, keys.Up): + if m.registry.idx > 0 { + m.registry.idx-- + m.clampRegistryScroll() + } + case key.Matches(msg, keys.Down): + items := m.filteredRegistryItems() + if m.registry.idx < len(items)-1 { + m.registry.idx++ + m.clampRegistryScroll() + } + case msg.Type == tea.KeyBackspace: + if len(m.registry.filter) > 0 { + r := []rune(m.registry.filter) + m.registry.filter = string(r[:len(r)-1]) + m.registry.idx = 0 + m.registry.scrollOff = 0 + } + default: + if msg.Type == tea.KeyRunes { + m.registry.filter += msg.String() + m.registry.idx = 0 + m.registry.scrollOff = 0 + } + } + return nil +} + +// handleRegistryDetailKey handles key input in the detail view. +func (m *Model) handleRegistryDetailKey(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + m.registry.detail = false + m.registry.detailScroll = 0 + case key.Matches(msg, keys.Up): + if m.registry.detailScroll > 0 { + m.registry.detailScroll-- + } + case key.Matches(msg, keys.Down): + m.registry.detailScroll++ + case key.Matches(msg, keys.CopyCurl): + // y copies the suggested `thv run` command for the selected registry item. + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + cmd := buildRunCmd(items[m.registry.idx]) + _ = clipboard.WriteAll(cmd) + return m.showNotif("✓ run command copied", true) + } + case key.Matches(msg, keys.Restart): + items := m.filteredRegistryItems() + if len(items) > 0 && m.registry.idx < len(items) { + return m.openRunForm(items[m.registry.idx]) + } + } + return nil +} + +// clampRegistryScroll adjusts the scroll offset so the selected item is visible. +func (m *Model) clampRegistryScroll() { + visible := m.registryVisibleRows() + if visible < 1 { + return + } + if m.registry.idx < m.registry.scrollOff { + m.registry.scrollOff = m.registry.idx + } + if m.registry.idx >= m.registry.scrollOff+visible { + m.registry.scrollOff = m.registry.idx - visible + 1 + } +} + +// registryVisibleRows returns how many item rows fit in the current overlay. +func (m *Model) registryVisibleRows() int { + // overlay height is ~70% of terminal, minus borders/header/search/footer (~8 lines) + h := m.height*70/100 - 8 + if h < 3 { + return 3 + } + return h +} + +// openRunForm initialises the run-from-registry form for the given item. +func (m *Model) openRunForm(item regtypes.ServerMetadata) tea.Cmd { + m.runForm = runFormState{ + open: true, + item: item, + fields: buildRunFormFields(item), + idx: 0, + scroll: 0, + } + if len(m.runForm.fields) > 0 { + m.runForm.fields[0].input.Focus() + } + return nil +} + +// handleRunFormKey handles key input while the run form is open. +func (m *Model) handleRunFormKey(msg tea.KeyMsg) tea.Cmd { + if m.runForm.running { + return nil + } + switch { + case key.Matches(msg, keys.Escape): + m.runForm.open = false + return nil + case key.Matches(msg, keys.Tab): + m.runFormNextField() + m.clampRunFormScroll() + return nil + case key.Matches(msg, keys.ShiftTab): + m.runFormPrevField() + m.clampRunFormScroll() + return nil + case key.Matches(msg, keys.Enter): + return m.runFormSubmit() + default: + return m.runFormForwardToField(msg) + } +} + +func (m *Model) runFormNextField() { + formNextField(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) runFormPrevField() { + formPrevField(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) blurAllRunFormFields() { + formBlurAll(m.runForm.fields, &m.runForm.idx) +} + +func (m *Model) runFormForwardToField(msg tea.KeyMsg) tea.Cmd { + return formForwardKey(m.runForm.fields, m.runForm.idx, msg) +} + +// runFormSubmit validates required fields and launches the run command. +func (m *Model) runFormSubmit() tea.Cmd { + if len(m.runForm.fields) == 0 { + return m.showNotif("✗ no form fields", false) + } + + // Validate required fields. + for _, f := range m.runForm.fields { + if f.required && strings.TrimSpace(f.input.Value()) == "" { + return m.showNotif("✗ "+f.name+" is required", false) + } + } + + workloadName := strings.TrimSpace(m.runForm.fields[0].input.Value()) + + secrets := make(map[string]string) + envs := make(map[string]string) + for _, f := range m.runForm.fields[1:] { + val := strings.TrimSpace(f.input.Value()) + if val == "" { + continue + } + if f.secret { + secrets[f.name] = val + } else { + envs[f.name] = val + } + } + + m.runForm.running = true + m.blurAllRunFormFields() + return runFromRegistry(m.ctx, m.runForm.item, workloadName, secrets, envs) +} + +// clampRunFormScroll ensures the focused field is visible in the form overlay. +func (m *Model) clampRunFormScroll() { + // Each field takes ~3 lines (label + optional desc + input). + // Visible area is roughly 70% of height minus header/footer. + visibleFields := max((m.height*70/100-8)/3, 2) + if m.runForm.idx < m.runForm.scroll { + m.runForm.scroll = m.runForm.idx + } + if m.runForm.idx >= m.runForm.scroll+visibleFields { + m.runForm.scroll = m.runForm.idx - visibleFields + 1 + } +} diff --git a/pkg/tui/update_search.go b/pkg/tui/update_search.go new file mode 100644 index 0000000000..2a6a3c07b6 --- /dev/null +++ b/pkg/tui/update_search.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// searchParams groups the mutable search state and associated viewport data +// needed by the shared search helpers. Callers construct this with pointers to +// the relevant Model fields so the helpers can read and write them in place. +type searchParams struct { + active *bool + query *string + matches *[]int + idx *int + lines []string + vp *viewport.Model + hOff int +} + +// handleSearchKey is the shared key handler for both log and proxy-log search. +func handleSearchKey(msg tea.KeyMsg, p searchParams) tea.Cmd { + switch { + case key.Matches(msg, keys.Escape): + // Esc clears the search entirely and restores normal log content. + *p.active = false + *p.query = "" + *p.matches = nil + *p.idx = 0 + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + case key.Matches(msg, keys.Enter): + // Enter closes the prompt but keeps highlights and the current match. + *p.active = false + case key.Matches(msg, keys.SearchNext): + if len(*p.matches) > 0 { + *p.idx = (*p.idx + 1) % len(*p.matches) + scrollToSearchMatch(p) + } + case key.Matches(msg, keys.SearchPrev): + if len(*p.matches) > 0 { + *p.idx = (*p.idx - 1 + len(*p.matches)) % len(*p.matches) + scrollToSearchMatch(p) + } + case msg.Type == tea.KeyBackspace: + if len(*p.query) > 0 { + // Remove last rune (not last byte) to handle multi-byte UTF-8. + r := []rune(*p.query) + *p.query = string(r[:len(r)-1]) + rebuildSearch(p) + } + default: + if msg.Type == tea.KeyRunes { + *p.query += msg.String() + rebuildSearch(p) + } + } + return nil +} + +// rebuildSearch recalculates which lines match the current query and +// refreshes the viewport content with highlights. +func rebuildSearch(p searchParams) { + *p.matches = nil + *p.idx = 0 + if *p.query == "" { + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + return + } + lq := strings.ToLower(*p.query) + for i, line := range p.lines { + if strings.Contains(strings.ToLower(line), lq) { + *p.matches = append(*p.matches, i) + } + } + // Clamp current index. + if *p.idx >= len(*p.matches) { + *p.idx = 0 + } + scrollToSearchMatch(p) +} + +// scrollToSearchMatch updates the viewport content with highlights and scrolls +// to the current match. +func scrollToSearchMatch(p searchParams) { + if len(*p.matches) == 0 { + // Re-render without highlights when there are no matches. + if *p.query != "" { + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, nil, 0, p.vp.Width, p.hOff)) + } + return + } + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, *p.matches, *p.idx, p.vp.Width, p.hOff)) + // Scroll the viewport so the current match line is visible. + matchLine := (*p.matches)[*p.idx] + p.vp.SetYOffset(matchLine) +} + +// logSearchParams builds searchParams for the main log panel. +func (m *Model) logSearchParams() searchParams { + return searchParams{ + active: &m.logSearchActive, + query: &m.logSearchQuery, + matches: &m.logSearchMatches, + idx: &m.logSearchIdx, + lines: m.logLines, + vp: &m.logView, + hOff: m.logHScrollOff, + } +} + +// proxyLogSearchParams builds searchParams for the proxy log panel. +func (m *Model) proxyLogSearchParams() searchParams { + return searchParams{ + active: &m.proxyLogSearchActive, + query: &m.proxyLogSearchQuery, + matches: &m.proxyLogSearchMatches, + idx: &m.proxyLogSearchIdx, + lines: m.proxyLogLines, + vp: &m.proxyLogView, + hOff: m.proxyLogHScrollOff, + } +} + +// handleLogSearchKey handles key input while the log search prompt is open. +func (m *Model) handleLogSearchKey(msg tea.KeyMsg) tea.Cmd { + return handleSearchKey(msg, m.logSearchParams()) +} + +// scrollToMatch updates the viewport content with highlights and scrolls to the current match. +func (m *Model) scrollToMatch() { + scrollToSearchMatch(m.logSearchParams()) +} + +// handleProxyLogSearchKey processes key events when proxy log search is active. +func (m *Model) handleProxyLogSearchKey(msg tea.KeyMsg) tea.Cmd { + return handleSearchKey(msg, m.proxyLogSearchParams()) +} + +// scrollToProxyMatch updates the proxy log viewport with highlights and scrolls to the current match. +func (m *Model) scrollToProxyMatch() { + scrollToSearchMatch(m.proxyLogSearchParams()) +} + +// buildHighlightedLogContent builds viewport content like buildHScrollContent but also +// highlights the search query within matching lines. The current focused match +// is highlighted with green; other matches with yellow. +func buildHighlightedLogContent(lines []string, query string, matches []int, currentMatchIdx int, viewW, hOff int) string { + if len(lines) == 0 { + return "" + } + if query == "" { + return buildHScrollContent(lines, viewW, hOff) + } + + // Build a set for fast match lookup. + matchSet := make(map[int]bool, len(matches)) + for _, idx := range matches { + matchSet[idx] = true + } + var currentMatchLine int + if len(matches) > 0 && currentMatchIdx < len(matches) { + currentMatchLine = matches[currentMatchIdx] + } + + lowerQuery := strings.ToLower(query) + + var sb strings.Builder + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + + if !matchSet[i] { + // Non-matching line: apply only h-scroll. + if viewW > 0 { + xansiLine := xansiCutLine(line, hOff, viewW) + sb.WriteString(xansiLine) + } else { + sb.WriteString(line) + } + continue + } + + // Matching line: inject highlights then h-scroll. + highlightBg := ui.ColorYellow + if i == currentMatchLine { + highlightBg = ui.ColorGreen + } + highlighted := highlightSubstring(line, query, lowerQuery, highlightBg) + + if viewW > 0 { + sb.WriteString(xansiCutLine(highlighted, hOff, viewW)) + } else { + sb.WriteString(highlighted) + } + } + return sb.String() +} + +// highlightSubstring wraps all case-insensitive occurrences of query within line +// with a lipgloss background color. It operates on rune indices so that +// multi-byte UTF-8 characters and Unicode case mappings are handled correctly. +func highlightSubstring(line, query, lowerQuery string, bg lipgloss.Color) string { + if query == "" { + return line + } + lineRunes := []rune(line) + lowerLineRunes := []rune(strings.ToLower(line)) + queryRunes := []rune(lowerQuery) + qLen := len(queryRunes) + hlStyle := lipgloss.NewStyle().Background(bg).Foreground(ui.ColorBg) + + var sb strings.Builder + pos := 0 + for pos <= len(lowerLineRunes)-qLen { + idx := runesIndex(lowerLineRunes[pos:], queryRunes) + if idx < 0 { + break + } + abs := pos + idx + sb.WriteString(string(lineRunes[pos:abs])) + sb.WriteString(hlStyle.Render(string(lineRunes[abs : abs+qLen]))) + pos = abs + qLen + } + sb.WriteString(string(lineRunes[pos:])) + return sb.String() +} + +// runesIndex returns the rune index of the first occurrence of sub in s, or -1. +func runesIndex(s, sub []rune) int { + if len(sub) == 0 { + return 0 + } + for i := 0; i <= len(s)-len(sub); i++ { + match := true + for j := range sub { + if s[i+j] != sub[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} + +// xansiCutLine applies ANSI-aware horizontal slicing to a single line. +// It is a thin wrapper around buildHScrollContent for a single line. +func xansiCutLine(line string, hOff, viewW int) string { + result := buildHScrollContent([]string{line}, viewW, hOff) + return result +} diff --git a/pkg/tui/view.go b/pkg/tui/view.go new file mode 100644 index 0000000000..a9c7a6127d --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// View renders the full TUI to a string. +// We build exactly m.height lines by slotting body lines into a fixed array +// and placing the 2-line statusbar at the last two rows. This avoids any +// off-by-one ambiguity from lipgloss Height padding or trailing-newline +// counting differences between lipgloss and BubbleTea's "\n"-split renderer. +// oscSetBg is the OSC 11 sequence that sets the terminal's own default +// background colour. Every cell that has no explicit background (log text, +// tool descriptions, text-input interiors, etc.) will inherit this colour, +// giving the whole TUI a uniform #1e2030 background without having to style +// every individual element. oscResetBg restores the original colour on exit. +const oscSetBg = "\x1b]11;#1e2030\x07" +const oscResetBg = "\x1b]111;\x07" + +// View implements tea.Model and renders the full TUI to a string. +func (m Model) View() string { + if m.quitting { + // Reset terminal background before handing control back to the shell. + return oscResetBg + } + if m.width == 0 || m.height < 2 { + return "Loading…\n" + } + + sidebar := m.renderSidebar() + main := m.renderMain() + + // Divider: exactly m.height-2 rows (no trailing \n) to match sidebar/main. + var dividerStr string + if m.height > 3 { + dividerStr = strings.Repeat("│\n", m.height-3) + "│" + } else { + dividerStr = "│" + } + divider := lipgloss.NewStyle(). + Foreground(ui.ColorDim). + Render(dividerStr) + + body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, divider, main) + statusbar := m.renderStatusBar() + + // Split the statusbar into its two component lines. + sbParts := strings.SplitN(statusbar, "\n", 2) + if len(sbParts) < 2 { + sbParts = append(sbParts, "") + } + + // bgRow fills any unfilled slots with the main background so no row ever + // shows the raw terminal background between the content and the statusbar. + bgRow := lipgloss.NewStyle().Width(m.width).Background(ui.ColorBg).Render("") + + // Build an explicit m.height-line slice so BubbleTea always fills the + // entire terminal window, regardless of any lipgloss rounding. + out := make([]string, m.height) + // Pre-fill every body slot with the background colour. + for i := range m.height - 2 { + out[i] = bgRow + } + bodyLines := strings.Split(body, "\n") + // Drop a trailing empty element that lipgloss may append. + if len(bodyLines) > 0 && bodyLines[len(bodyLines)-1] == "" { + bodyLines = bodyLines[:len(bodyLines)-1] + } + for i, l := range bodyLines { + if i >= m.height-2 { + break + } + out[i] = l + } + out[m.height-2] = sbParts[0] + out[m.height-1] = sbParts[1] + + // Prepend the OSC 11 sequence so the terminal's default background is + // #1e2030 for this frame. Every area with no explicit background colour + // (log lines, tool text, text-input interiors, …) will therefore show + // the same dark tone as the statusbar with no further changes needed. + full := oscSetBg + strings.Join(out, "\n") + + if m.showHelp { + return m.renderHelpOverlay() + } + if m.registry.open { + return m.renderRegistryOverlay() + } + return full +} + +// renderSidebar renders the left server list. +func (m Model) renderSidebar() string { + sw := sidebarW(m.width) + + titleStyle := lipgloss.NewStyle(). + Foreground(ui.ColorPurple). + Bold(true). + Width(sw) + + list := m.filteredWorkloads() + running, stopped := countStatuses(m.workloads) + summary := lipgloss.NewStyle().Foreground(ui.ColorDim). + Render(fmt.Sprintf("%dr · %ds", running, stopped)) + + header := titleStyle.Render("SERVERS") + " " + summary + "\n" + + var sb strings.Builder + sb.WriteString(header) + + for i, w := range list { + dot := ui.RenderStatusDot(w.Status) + name := w.Name + port := fmt.Sprintf(":%d", w.Port) + + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + portStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + + if i == m.selectedIdx { + nameStyle = nameStyle.Background(lipgloss.Color("#2a2e45")).Bold(true) + portStyle = portStyle.Background(lipgloss.Color("#2a2e45")) + } + + line1 := fmt.Sprintf("%s %s%s", + dot, + nameStyle.Render(truncateSidebar(name, sw-8)), + portStyle.Render(port), + ) + sb.WriteString(" " + line1 + "\n") + + // Show group on a second line if present + if w.Group != "" { + groupLine := lipgloss.NewStyle(). + Foreground(ui.ColorDim2). + Render(" " + w.Group) + sb.WriteString(groupLine + "\n") + } + } + + // Filter prompt + if m.filterActive { + prompt := lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.filterQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + sb.WriteString("\n" + prompt + "\n") + } + + sidebarStyle := lipgloss.NewStyle(). + Width(sw). + Height(m.height - 2).MaxHeight(m.height - 2). // body = m.height-2, statusbar = 2, total = m.height + PaddingRight(1) + + return sidebarStyle.Render(sb.String()) +} + +// renderMain renders the main content panel (logs or info). +// +//nolint:gocyclo // builds the full main-area layout; the toolbar sub-sections are tightly coupled to panel state +func (m Model) renderMain() string { + sw := sidebarW(m.width) + mainW := m.width - sw - 1 + if mainW < 10 { + mainW = 10 + } + + sel := m.selected() + + // Title bar + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorBlue).Bold(true) + var titleText string + if sel != nil { + titleText = titleStyle.Render("toolhive") + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" / ") + + lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render(sel.Name) + } else { + titleText = titleStyle.Render("toolhive") + } + + // Tab bar + logsTab := m.renderTab("Logs", panelLogs) + infoTab := m.renderTab("Info", panelInfo) + toolsTab := m.renderTab("Tools", panelTools) + proxyTab := m.renderTab("Proxy Logs", panelProxyLogs) + inspTab := m.renderTab("Inspector", panelInspector) + tabBar := logsTab + " " + infoTab + " " + toolsTab + " " + proxyTab + " " + inspTab + + // Separator + sep := lipgloss.NewStyle().Foreground(ui.ColorDim). + Render(strings.Repeat("─", mainW)) + + // Content + var content string + switch m.panel { + case panelLogs: + content = m.logView.View() + case panelInfo: + if sel != nil { + content = renderInfo(sel, m.runConfig, mainW) + } else { + content = lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + case panelTools: + content = m.renderTools(mainW) + case panelProxyLogs: + content = m.renderProxyLogs(mainW) + case panelInspector: + content = m.renderInspector(mainW) + } + + // Log toolbar (only on logs/proxy logs panels) + toolbar := "" + dimToolbar := lipgloss.NewStyle().Foreground(ui.ColorDim) + if m.panel == panelLogs { + if m.logSearchActive || m.logSearchQuery != "" { + // Search toolbar: show prompt or active query with match count. + queryPart := func() string { + if m.logSearchActive { + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.logSearchQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + } + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.logSearchQuery) + }() + matchPart := func() string { + if len(m.logSearchMatches) == 0 { + return " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("no matches") + } + return " " + lipgloss.NewStyle().Foreground(ui.ColorGreen).Render( + fmt.Sprintf("match %d/%d", m.logSearchIdx+1, len(m.logSearchMatches)), + ) + dimToolbar.Render(" (n=next N=prev esc=clear)") + }() + toolbar = " " + queryPart + matchPart + } else { + followStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + if m.logFollow { + followStyle = followStyle.Foreground(ui.ColorGreen) + } + hScrollHint := dimToolbar.Render(" ←→ scroll") + if m.logHScrollOff > 0 { + hScrollHint = dimToolbar.Render(fmt.Sprintf(" ←→ +%d", m.logHScrollOff)) + } + toolbar = " " + followStyle.Render("follow") + + dimToolbar.Render(" (f to toggle)") + hScrollHint + } + } + if m.panel == panelProxyLogs { + if m.proxyLogSearchActive || m.proxyLogSearchQuery != "" { + queryPart := func() string { + if m.proxyLogSearchActive { + return lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.proxyLogSearchQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + } + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.proxyLogSearchQuery) + }() + matchPart := func() string { + if len(m.proxyLogSearchMatches) == 0 { + return " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("no matches") + } + return " " + lipgloss.NewStyle().Foreground(ui.ColorGreen).Render( + fmt.Sprintf("match %d/%d", m.proxyLogSearchIdx+1, len(m.proxyLogSearchMatches)), + ) + dimToolbar.Render(" (n=next N=prev esc=clear)") + }() + toolbar = " " + queryPart + matchPart + } else if m.proxyLogFor != "" { + hScrollHint := dimToolbar.Render(" ←→ scroll") + if m.proxyLogHScrollOff > 0 { + hScrollHint = dimToolbar.Render(fmt.Sprintf(" ←→ +%d", m.proxyLogHScrollOff)) + } + toolbar = " " + dimToolbar.Render(fmt.Sprintf("source: toolhive/logs/%s.log", m.proxyLogFor)) + + hScrollHint + } + } + + mainStyle := lipgloss.NewStyle().Width(mainW).Height(m.height - 2).MaxHeight(m.height - 2) + + // Only include toolbar if non-empty to avoid a trailing blank line. + bodyParts := []string{titleText, tabBar, sep, content} + if toolbar != "" { + bodyParts = append(bodyParts, toolbar) + } + body := strings.Join(bodyParts, "\n") + + return mainStyle.Render(body) +} + +// renderTab renders a single tab, highlighted if active. +func (m Model) renderTab(label string, p activePanel) string { + if m.panel == p { + return lipgloss.NewStyle(). + Foreground(ui.ColorBlue). + Bold(true). + Underline(true). + Render("[" + label + "]") + } + return lipgloss.NewStyle(). + Foreground(ui.ColorDim2). + Render("[" + label + "]") +} + +// renderProxyLogs renders the proxy log panel. +func (m Model) renderProxyLogs(width int) string { + _ = width + if m.selected() == nil { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + if len(m.proxyLogLines) == 0 { + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Waiting for proxy logs…") + } + return m.proxyLogView.View() +} diff --git a/pkg/tui/view_helpers.go b/pkg/tui/view_helpers.go new file mode 100644 index 0000000000..4913448730 --- /dev/null +++ b/pkg/tui/view_helpers.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" +) + +// renderFormFieldFromStruct renders a formField with its metadata as a labelled input. +func renderFormFieldFromStruct(f formField, focused bool, width int) []string { + tag := "" + if f.typeName != "" { + tag = "[" + f.typeName + "]" + } else if f.secret { + tag = "(secret)" + } + return renderFormField(f.name, f.desc, tag, f.required, focused, f.input, width) +} + +// renderFormField renders a single labelled form field with optional description, +// required marker, extra tag, and a bordered text input. It returns the rendered +// lines (label, optional description, input) as a slice of strings. +func renderFormField(name, desc, extraTag string, required, focused bool, input textinput.Model, width int) []string { + var lines []string + + reqMark := "" + if required { + reqMark = lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render(" *") + } + tag := "" + if extraTag != "" { + tag = " " + lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(extraTag) + } + label := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render(name) + reqMark + tag + lines = append(lines, label) + + if desc != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" "+truncateSidebar(desc, width-4))) + } + + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorDim). + Width(width - 4) + if focused { + inputStyle = inputStyle.BorderForeground(ui.ColorCyan) + } + lines = append(lines, inputStyle.Render(input.View())) + + return lines +} + +// inspCopyBadge renders a small [KEY] LABEL badge for the inspector headers. +func inspCopyBadge(key, label string) string { + keyPart := lipgloss.NewStyle(). + Background(lipgloss.Color("#2a2f45")). + Foreground(ui.ColorText). + Bold(true). + Render(" " + key + " ") + labelPart := lipgloss.NewStyle(). + Background(lipgloss.Color("#1a1d2e")). + Foreground(ui.ColorDim2). + Render(" " + label + " ") + return keyPart + labelPart +} + +// renderCurlLine applies syntax highlighting to a single line of a curl command. +func renderCurlLine(line string) string { + trimmed := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(trimmed)] + + keyword := lipgloss.NewStyle().Foreground(ui.ColorBlue).Bold(true) + flagStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple) + methodStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow).Bold(true) + urlStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + strStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + + switch { + case strings.HasPrefix(trimmed, "curl "): + // "curl -X POST \" + rest := trimmed[5:] + // rest should be "-X POST \" + parts := strings.Fields(rest) + out := keyword.Render("curl") + " " + if len(parts) >= 2 && parts[0] == "-X" { + out += flagStyle.Render("-X") + " " + methodStyle.Render(parts[1]) + if len(parts) > 2 { + out += " " + dimStyle.Render(strings.Join(parts[2:], " ")) + } + } else { + out += dimStyle.Render(rest) + } + return indent + out + case strings.HasPrefix(trimmed, "'http"): + // URL line: 'http://...' \ + idx := strings.LastIndex(trimmed, "'") + if idx > 0 { + url := trimmed[:idx+1] + suffix := strings.TrimSpace(trimmed[idx+1:]) + out := urlStyle.Render(url) + if suffix != "" { + out += " " + dimStyle.Render(suffix) + } + return indent + out + } + return indent + urlStyle.Render(trimmed) + case strings.HasPrefix(trimmed, "-H "): + // -H 'Header: value' \ + rest := trimmed[3:] + return indent + flagStyle.Render("-H") + " " + strStyle.Render(rest) + case strings.HasPrefix(trimmed, "-d "): + // -d '...' + rest := trimmed[3:] + return indent + flagStyle.Render("-d") + " " + dimStyle.Render(rest) + default: + return indent + dimStyle.Render(trimmed) + } +} + +// wrapText wraps text to fit within maxW runes per line, with a given indent prefix. +func wrapText(text string, maxW int, indent string) []string { + words := strings.Fields(text) + var lines []string + line := indent + for _, w := range words { + candidate := line + w + if line != indent { + candidate = line + " " + w + } + if len([]rune(candidate)) > maxW && line != indent { + lines = append(lines, line) + line = indent + w + } else { + line = candidate + } + } + if line != indent { + lines = append(lines, line) + } + return lines +} + +// runesTruncate truncates s to at most n runes, appending "..." if truncated. +func runesTruncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +// truncateSidebar shortens s to n runes. +func truncateSidebar(s string, n int) string { + if n <= 0 { + return s + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n-1]) + "…" +} + +// countStatuses counts running vs stopped workloads. +func countStatuses(list []core.Workload) (running, stopped int) { + for _, w := range list { + switch w.Status { + case rt.WorkloadStatusRunning, rt.WorkloadStatusUnauthenticated, rt.WorkloadStatusUnhealthy: + running++ + case rt.WorkloadStatusStopped, rt.WorkloadStatusError, rt.WorkloadStatusStarting, + rt.WorkloadStatusStopping, rt.WorkloadStatusRemoving, rt.WorkloadStatusUnknown: + stopped++ + } + } + return +} diff --git a/pkg/tui/view_info.go b/pkg/tui/view_info.go new file mode 100644 index 0000000000..9688ad907b --- /dev/null +++ b/pkg/tui/view_info.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" + "github.com/stacklok/toolhive/pkg/core" + "github.com/stacklok/toolhive/pkg/runner" +) + +// renderInfo renders key-value info for the selected workload, enriched with RunConfig. +func renderInfo(w *core.Workload, cfg *runner.RunConfig, width int) string { + _ = width + styles := infoStyles{ + dim2: lipgloss.NewStyle().Foreground(ui.ColorDim2), + text: lipgloss.NewStyle().Foreground(ui.ColorText), + dim: lipgloss.NewStyle().Foreground(ui.ColorDim), + cyan: lipgloss.NewStyle().Foreground(ui.ColorCyan), + yellow: lipgloss.NewStyle().Foreground(ui.ColorYellow), + green: lipgloss.NewStyle().Foreground(ui.ColorGreen), + } + + var lines []string + lines = append(lines, renderInfoRuntime(w, styles)...) + if cfg == nil { + lines = append(lines, "\n"+styles.dim2.Render(" Loading config…")) + return strings.Join(lines, "\n") + } + lines = append(lines, renderInfoConfig(cfg, styles)...) + return strings.Join(lines, "\n") +} + +type infoStyles struct { + dim2, text, dim, cyan, yellow, green lipgloss.Style +} + +func (s infoStyles) row(key, val string) string { + return s.dim2.Render(fmt.Sprintf(" %-14s", key)) + s.text.Render(val) +} + +func (s infoStyles) section(title string) string { + return "\n" + s.dim.Render(" "+strings.Repeat("─", 30)) + "\n" + + s.dim.Render(fmt.Sprintf(" %s", strings.ToUpper(title))) + "\n" +} + +func renderInfoRuntime(w *core.Workload, s infoStyles) []string { + lines := []string{s.section("Runtime")} + lines = append(lines, s.row("Name", w.Name)) + lines = append(lines, s.row("Status", string(w.Status))) + lines = append(lines, s.row("URL", w.URL)) + lines = append(lines, s.row("Port", fmt.Sprintf("%d", w.Port))) + lines = append(lines, s.row("Transport", string(w.TransportType))) + if w.Group != "" { + lines = append(lines, s.row("Group", w.Group)) + } + if w.Remote { + lines = append(lines, s.row("Remote", "yes")) + } + lines = append(lines, s.row("Created", w.CreatedAt.Format("2006-01-02 15:04:05"))) + return lines +} + +func renderInfoConfig(cfg *runner.RunConfig, s infoStyles) []string { + var lines []string + if cfg.Image != "" { + lines = append(lines, s.section("Image")) + lines = append(lines, s.row("Image", cfg.Image)) + } + if len(cfg.EnvVars) > 0 { + lines = append(lines, s.section("Environment")) + envKeys := make([]string, 0, len(cfg.EnvVars)) + for k := range cfg.EnvVars { + envKeys = append(envKeys, k) + } + slices.Sort(envKeys) + for _, k := range envKeys { + lines = append(lines, s.cyan.Render(fmt.Sprintf(" %-16s", k))+s.dim2.Render(cfg.EnvVars[k])) + } + } + if len(cfg.Volumes) > 0 { + lines = append(lines, s.section("Volumes")) + for _, v := range cfg.Volumes { + lines = append(lines, renderInfoVolumeLine(v, s)) + } + } + if len(cfg.Secrets) > 0 { + lines = append(lines, s.section("Secrets")) + for _, sec := range cfg.Secrets { + lines = append(lines, " "+s.yellow.Render(sec)) + } + } + if cfg.PermissionProfile != nil { + lines = append(lines, renderInfoPermissions(cfg, s)...) + } + return lines +} + +func renderInfoVolumeLine(v string, s infoStyles) string { + parts := strings.SplitN(v, ":", 3) + mode := "" + if len(parts) == 3 { + mode = " " + s.dim.Render("["+parts[2]+"]") + } + host := s.dim2.Render(fmt.Sprintf(" %-24s", parts[0])) + arrow := s.dim.Render("→ ") + var cont string + if len(parts) >= 2 { + cont = s.text.Render(parts[1]) + } + return host + arrow + cont + mode +} + +func renderInfoPermissions(cfg *runner.RunConfig, s infoStyles) []string { + lines := []string{s.section("Permissions")} + outbound := cfg.PermissionProfile.Network.Outbound + prefix := " " + s.dim2.Render("network outbound ") + switch { + case outbound.InsecureAllowAll: + lines = append(lines, prefix+s.yellow.Render("allow all")) + case len(outbound.AllowHost) > 0: + lines = append(lines, prefix+s.green.Render(strings.Join(outbound.AllowHost, ", "))) + default: + lines = append(lines, prefix+s.dim.Render("denied")) + } + return lines +} diff --git a/pkg/tui/view_inspector.go b/pkg/tui/view_inspector.go new file mode 100644 index 0000000000..eab1f7d545 --- /dev/null +++ b/pkg/tui/view_inspector.go @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// renderInspector renders the 3-column tool inspector panel. +func (m Model) renderInspector(mainW int) string { + toolListW := 22 + remaining := mainW - toolListW - 2 // 2 for separator columns + if remaining < 20 { + remaining = 20 + } + responseW := remaining * 55 / 100 + formW := remaining - responseW - 1 + + // mainStyle Height = m.height-2; title(1)+tabBar(1)+sep(1) = 3 overhead → inspH = m.height-5 + inspH := m.height - 5 + if inspH < 5 { + inspH = 5 + } + + leftCol := m.renderInspToolList(toolListW, inspH) + middleCol := m.renderInspForm(formW, inspH) + rightCol := m.renderInspResponse(responseW, inspH) + + // Full-height vertical separators between columns. + sepStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + vline := sepStyle.Render(strings.Repeat("│\n", inspH-1) + "│") + + base := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, vline, middleCol, vline, rightCol) + + // Tool info modal overlaid on top when active. + if m.insp.showInfo { + return m.renderToolInfoModal(base, mainW, inspH) + } + return base +} + +// renderToolInfoModal renders a centered modal with the selected tool's description. +func (m Model) renderToolInfoModal(base string, w, h int) string { + filtered := m.filteredTools() + if len(filtered) == 0 || m.insp.toolIdx >= len(filtered) { + return base + } + tool := filtered[m.insp.toolIdx] + + modalW := min(w-8, 64) + innerW := modalW - 6 // padding 1,3 + + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + + sep := dimStyle.Render(strings.Repeat("─", innerW)) + desc := tool.Description + if desc == "" { + desc = "(no description available)" + } + + var sb strings.Builder + sb.WriteString(titleStyle.Render(tool.Name) + " " + hintStyle.Render("press any key to close") + "\n") + sb.WriteString(sep + "\n") + for _, line := range wrapText(desc, innerW, "") { + sb.WriteString(textStyle.Render(line) + "\n") + } + + modal := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple). + Padding(1, 3). + Width(modalW). + Render(sb.String()) + + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, + modal, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// renderInspToolList renders the left tool-list column of the inspector. +func (m Model) renderInspToolList(width, height int) string { + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", width)) + + filtered := m.filteredTools() + countStr := fmt.Sprintf("(%d)", len(m.tools)) + if m.insp.filterActive || m.insp.filterQuery != "" { + countStr = fmt.Sprintf("(%d/%d)", len(filtered), len(m.tools)) + } + header := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true). + Render("TOOLS " + countStr) + + var sb strings.Builder + sb.WriteString(header + "\n") + sb.WriteString(sep + "\n") + + if m.toolsLoading { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Loading…") + "\n") + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + if errors.Is(m.toolsErr, errStdioToolsNotAvailable) { + for _, line := range wrapText(" "+m.toolsErr.Error(), width, " ") { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(line) + "\n") + } + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + if m.toolsErr != nil { + for _, line := range wrapText(" "+m.toolsErr.Error(), width, " ") { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorRed).Render(line) + "\n") + } + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) + } + + // Filter prompt. + if m.insp.filterActive { + prompt := lipgloss.NewStyle().Foreground(ui.ColorYellow).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.insp.filterQuery) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + sb.WriteString(prompt + "\n") + } else if m.insp.filterQuery != "" { + prompt := lipgloss.NewStyle().Foreground(ui.ColorDim2).Render("/") + + lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(m.insp.filterQuery) + sb.WriteString(prompt + "\n") + } + + selBg := lipgloss.Color("#2a2e45") + infoIcon := lipgloss.NewStyle().Foreground(ui.ColorDim).Render("ℹ") + for i, t := range filtered { + // Reserve 2 chars for the ℹ icon on selected row. + name := truncateSidebar(t.Name, width-4) + if i == m.insp.toolIdx { + namePart := lipgloss.NewStyle(). + Foreground(ui.ColorText). + Background(selBg). + Bold(true). + Render(" " + name) + iconPart := lipgloss.NewStyle().Background(selBg).Render(" " + infoIcon) + line := lipgloss.NewStyle().Background(selBg).Width(width). + Render(namePart + iconPart) + sb.WriteString(line + "\n") + } else { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" "+name) + "\n") + } + } + if len(filtered) == 0 && !m.toolsLoading { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" no match") + "\n") + } + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderInspForm renders the middle form column of the inspector. +func (m Model) renderInspForm(width, height int) string { + filtered := m.filteredTools() + if len(filtered) == 0 { + return lipgloss.NewStyle().Width(width).Height(height). + Foreground(ui.ColorDim).Render(" No tools available") + } + if m.insp.toolIdx >= len(filtered) { + return lipgloss.NewStyle().Width(width).Height(height). + Foreground(ui.ColorDim).Render(" No tools available") + } + + tool := filtered[m.insp.toolIdx] + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", width)) + + var sb strings.Builder + // Tool name and description (capped to 2 lines; press 'i' for full description). + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorCyan).Bold(true).Render(tool.Name) + "\n") + if tool.Description != "" { + descLines := wrapText(tool.Description, width-2, "") + const maxDescLines = 2 + if len(descLines) > maxDescLines { + descLines = descLines[:maxDescLines] + descLines[maxDescLines-1] += lipgloss.NewStyle().Foreground(ui.ColorDim).Render("… [i] more") + } + for _, line := range descLines { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(line) + "\n") + } + } + sb.WriteString(sep + "\n") + + // Form fields + for i, f := range m.insp.fields { + for _, line := range renderFormFieldFromStruct(f, i == m.insp.fieldIdx, width) { + sb.WriteString(line + "\n") + } + if i < len(m.insp.fields)-1 { + sb.WriteString(sep + "\n") + } + } + + if len(m.insp.fields) == 0 { + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" (no parameters)") + "\n") + } + + sb.WriteString("\n") + + // "↵ Call tool" button — left side. + callBtn := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorBlue). + Foreground(ui.ColorBlue). + Padding(0, 2). + Render("↵ Call tool") + + sb.WriteString(callBtn) + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderInspResponse renders the right response column of the inspector. +// +//nolint:gocyclo // renders all response states; splitting would scatter related view logic +func (m Model) renderInspResponse(width, height int) string { + sel := m.selected() + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + + // REQUEST header — title left, [Y] COPY CURL badge at far right. + reqTitle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render("REQUEST") + copyCurlBadge := inspCopyBadge("y", "COPY CURL") + reqGap := width - 2 - ui.VisibleLen(reqTitle) - ui.VisibleLen(copyCurlBadge) + if reqGap < 1 { + reqGap = 1 + } + sb.WriteString(reqTitle + strings.Repeat(" ", reqGap) + copyCurlBadge + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + + if ft := m.filteredTools(); sel != nil && len(ft) > 0 && m.insp.toolIdx < len(ft) { + tool := ft[m.insp.toolIdx] + args := inspFieldValues(m.insp.fields) + curl := buildCurlStr(sel, tool.Name, args) + for _, line := range strings.Split(curl, "\n") { + sb.WriteString(renderCurlLine(line) + "\n") + } + } else { + sb.WriteString(dimStyle.Render(" Type arguments and press ↵ to call") + "\n") + } + + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + sb.WriteString("\n") + + // RESPONSE header — title + status left, [C] COPY JSON badge at far right when result available. + respTitle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true).Render("RESPONSE") + statusBadge := "" + if !m.insp.loading && m.insp.result != "" { + if m.insp.resultOK { + statusBadge = " " + lipgloss.NewStyle().Foreground(ui.ColorGreen). + Render(fmt.Sprintf("✓ SUCCESS %dms", m.insp.resultMs)) + } else { + statusBadge = " " + lipgloss.NewStyle().Foreground(ui.ColorRed).Render("✗ ERROR") + } + } + copyJSONBadge := "" + if m.insp.result != "" { + copyJSONBadge = inspCopyBadge("c", "COPY JSON") + } + respLeft := respTitle + statusBadge + if copyJSONBadge != "" { + respGap := width - 2 - ui.VisibleLen(respLeft) - ui.VisibleLen(copyJSONBadge) + if respGap < 1 { + respGap = 1 + } + sb.WriteString(respLeft + strings.Repeat(" ", respGap) + copyJSONBadge + "\n") + } else { + sb.WriteString(respLeft + "\n") + } + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + + switch { + case m.insp.loading: + frame := inspSpinFrames[m.insp.spinFrame%len(inspSpinFrames)] + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorCyan).Render(frame+" Calling…") + "\n") + case m.insp.result != "" && m.insp.jsonRoot != nil: + // REQUEST header (1) + sep (1) + curl (~3) + sep (1) + RESPONSE header (1) + sep (1) = ~8 overhead. + // Subtract additional log section height if log lines are present. + const treeHeaderOverhead = 8 + const logSectionHeight = 9 // blank + sep + LOGS + 6 log lines + treeH := height - treeHeaderOverhead + if len(m.insp.logLines) > 0 { + treeH -= logSectionHeight + } + if treeH < 3 { + treeH = 3 + } + sb.WriteString(renderJSONTree(m.insp.treeVis, m.insp.treeCursor, m.insp.treeScroll, width, treeH)) + case m.insp.result != "": + sb.WriteString(m.insp.respView.View()) + default: + sb.WriteString(dimStyle.Render(" Response will appear here") + "\n") + } + + // LOGS section — shown below the response whenever there are TUI log messages. + if len(m.insp.logLines) > 0 { + sb.WriteString("\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", width-2)) + "\n") + sb.WriteString(lipgloss.NewStyle().Foreground(ui.ColorYellow).Bold(true).Render("LOGS") + "\n") + sb.WriteString(m.insp.logView.View()) + } + + return lipgloss.NewStyle().Width(width).Height(height).Render(sb.String()) +} + +// renderTools renders the tools list for the selected workload using the toolsView viewport. +func (m Model) renderTools(_ int) string { + if m.selected() == nil { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render("No server selected") + } + if m.toolsLoading { + return lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Loading tools…") + } + if errors.Is(m.toolsErr, errStdioToolsNotAvailable) { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" " + m.toolsErr.Error()) + } + if m.toolsErr != nil { + return lipgloss.NewStyle().Foreground(ui.ColorRed).Render(" Error: " + m.toolsErr.Error()) + } + if len(m.tools) == 0 { + return lipgloss.NewStyle().Foreground(ui.ColorDim).Render(" No tools available") + } + return m.toolsView.View() +} + +// buildToolsContent builds the full scrollable content string for the tools viewport. +// selectedIdx highlights the currently selected tool (-1 for none). +func buildToolsContent(tools []vmcp.Tool, width, selectedIdx int) string { + nameW := 28 + descW := width - nameW - 4 + if descW < 20 { + descW = 20 + } + + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + countStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + selBg := lipgloss.NewStyle().Background(lipgloss.Color("#2a2f45")) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + + var sb strings.Builder + sb.WriteString(" " + countStyle.Render(fmt.Sprintf("%d tools", len(tools)))) + sb.WriteString(" " + hintStyle.Render("↵ open in inspector")) + sb.WriteString("\n\n") + + for i, t := range tools { + name := truncateSidebar(t.Name, nameW) + namePart := " " + ui.PadToWidth(nameStyle.Render(name), nameW+2) + + var lines []string + if t.Description != "" { + lines = wrapText(t.Description, descW, "") + } + + selected := i == selectedIdx + renderLine := func(s string) string { + if selected { + return selBg.Width(width - 2).Render(s) + } + return s + } + + if len(lines) == 0 { + sb.WriteString(renderLine(namePart) + "\n") + continue + } + for j, line := range lines { + if j == 0 { + sb.WriteString(renderLine(namePart+descStyle.Render(line)) + "\n") + } else { + indent := strings.Repeat(" ", nameW+4) + sb.WriteString(renderLine(indent+descStyle.Render(line)) + "\n") + } + } + } + return sb.String() +} diff --git a/pkg/tui/view_registry.go b/pkg/tui/view_registry.go new file mode 100644 index 0000000000..d262f58bcc --- /dev/null +++ b/pkg/tui/view_registry.go @@ -0,0 +1,422 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// registryBoxDims returns the shared overlay dimensions. +func (m Model) registryBoxDims() (boxW, innerW, visibleRows int) { + boxW = max(m.width*80/100, 50) + innerW = boxW - 4 // border (1 each side) + padding (1 each side) + visibleRows = max(m.height*70/100, 6) + return +} + +// renderRegistryOverlay renders the registry browser overlay. +// It delegates to the run form or detail view as appropriate. +func (m Model) renderRegistryOverlay() string { + if m.runForm.open { + return m.renderRunFormOverlay() + } + if m.registry.detail { + return m.renderRegistryDetailOverlay() + } + return m.renderRegistryListOverlay() +} + +// renderRegistryListOverlay renders the searchable list of registry items. +func (m Model) renderRegistryListOverlay() string { + items := m.filteredRegistryItems() + boxW, innerW, visibleRows := m.registryBoxDims() + + // Layout: header(1) + sep(1) + filter(1) + sep(1) + footer-sep(1) + footer(1) + border/pad(4) + const fixedLines = 10 + itemRows := max(visibleRows-fixedLines, 2) + + // Column widths: 2-space indent + name(nameW) + 2-space gap + desc + 2-space gap + tag(tagW) + const tagColW = 10 // " " + up to 8 chars + nameW := max(innerW*28/100, 16) + descW := max(innerW-2-nameW-2-tagColW, 10) // innerW = 2+nameW+2+descW+tagColW + + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + nameStyle := lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + tagStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + selBg := lipgloss.NewStyle().Background(lipgloss.Color("#2a2e45")) + + var sb strings.Builder + + // Header + sb.WriteString(titleStyle.Render("REGISTRY") + + " " + hintStyle.Render("↑↓ navigate enter detail esc close type to filter") + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + + // Filter line + sb.WriteString(dimStyle.Render(" ⌕ ") + + lipgloss.NewStyle().Foreground(ui.ColorText).Render(m.registry.filter) + + lipgloss.NewStyle().Foreground(ui.ColorDim).Render("█") + "\n") + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + + // Items + switch { + case m.registry.loading: + sb.WriteString("\n " + dimStyle.Render("Loading registry…") + "\n") + case m.registry.err != nil: + sb.WriteString("\n " + lipgloss.NewStyle().Foreground(ui.ColorRed). + Render("Error: "+m.registry.err.Error()) + "\n") + case len(items) == 0: + sb.WriteString("\n " + dimStyle.Render("No servers found") + "\n") + default: + end := min(m.registry.scrollOff+itemRows, len(items)) + for i, item := range items[m.registry.scrollOff:end] { + globalIdx := m.registry.scrollOff + i + name := truncateSidebar(item.GetName(), nameW) + desc := runesTruncate(item.GetDescription(), descW) + tagStr := registryTagStr(item.GetTags(), tagStyle) + + line := " " + ui.PadToWidth(nameStyle.Render(name), nameW+2) + + ui.PadToWidth(descStyle.Render(desc), descW+2) + tagStr + if globalIdx == m.registry.idx { + line = selBg.Width(innerW).Render(line) + } + sb.WriteString(line + "\n") + } + if len(items) > itemRows { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d–%d / %d", + m.registry.scrollOff+1, end, len(items))) + "\n") + } + } + + sb.WriteString(dimStyle.Render(strings.Repeat("─", innerW)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d servers available", len(m.registry.items)))) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// renderRegistryDetailOverlay renders the full detail view for the selected registry item. +func (m Model) renderRegistryDetailOverlay() string { + boxW, innerW, visibleRows := m.registryBoxDims() + items := m.filteredRegistryItems() + if len(items) == 0 || m.registry.idx >= len(items) { + return m.renderRegistryListOverlay() + } + item := items[m.registry.idx] + + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + sep := dimStyle.Render(strings.Repeat("─", innerW)) + lines := buildDetailLines(item, innerW, sep) + + const headerLines = 2 + contentLines := visibleRows - headerLines - 2 + total := len(lines) + scrollOff := min(m.registry.detailScroll, max(total-contentLines, 0)) + end := min(scrollOff+contentLines, total) + + var sb strings.Builder + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + breadcrumb := titleStyle.Render("REGISTRY") + dimStyle.Render(" / ") + + textStyle.Bold(true).Render(item.GetName()) + sb.WriteString(breadcrumb + " " + hintStyle.Render("↑↓ scroll r=run y=copy cmd esc back") + "\n") + sb.WriteString(sep + "\n") + for _, l := range lines[scrollOff:end] { + sb.WriteString(l + "\n") + } + if total > contentLines { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d lines", scrollOff+end-scrollOff, total))) + } + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// registryTagStr formats the first tag for the list column. +func registryTagStr(tags []string, style lipgloss.Style) string { + if len(tags) == 0 { + return "" + } + t := tags[0] + if len([]rune(t)) > 8 { + t = string([]rune(t)[:7]) + "…" + } + return style.Render(t) +} + +// buildDetailLines builds the scrollable content lines for a registry item detail view. +func buildDetailLines(item regtypes.ServerMetadata, innerW int, sep string) []string { + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + labelStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + cyanStyle := lipgloss.NewStyle().Foreground(ui.ColorCyan) + greenStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + yellowStyle := lipgloss.NewStyle().Foreground(ui.ColorYellow) + + detailRow := func(key, val string) string { + return labelStyle.Render(fmt.Sprintf(" %-14s", key)) + textStyle.Render(val) + } + section := func(title string) string { + return "\n" + sep + "\n" + dimStyle.Render(" "+strings.ToUpper(title)) + } + + var lines []string + lines = append(lines, buildDetailHeader(item, dimStyle, labelStyle, textStyle, greenStyle, yellowStyle, cyanStyle)...) + + if desc := item.GetDescription(); desc != "" { + lines = append(lines, section("Description")) + lines = append(lines, wrapText(desc, innerW-4, " ")...) + } + if repo := item.GetRepositoryURL(); repo != "" { + lines = append(lines, section("Repository")) + lines = append(lines, detailRow("URL", repo)) + } + lines = append(lines, buildDetailServerType(item, section, detailRow)...) + lines = append(lines, buildDetailTools(item, innerW, section, cyanStyle, labelStyle)...) + lines = append(lines, "\n"+sep) + return lines +} + +// buildDetailHeader returns the name/tier/status/transport/meta lines. +func buildDetailHeader( + item regtypes.ServerMetadata, + dimStyle, labelStyle, textStyle, greenStyle, yellowStyle, cyanStyle lipgloss.Style, +) []string { + meta := item.GetMetadata() + starsStr := "" + if meta != nil && meta.Stars > 0 { + starsStr = " " + dimStyle.Render(fmt.Sprintf("★ %d", meta.Stars)) + } + lastUpdStr := "" + if meta != nil && meta.LastUpdated != "" { + lastUpdStr = " " + dimStyle.Render("updated "+meta.LastUpdated[:min(len(meta.LastUpdated), 10)]) + } + + tierStr := func() string { + switch item.GetTier() { + case "Official": + return greenStyle.Render("Official") + case "": + return "" + default: + return yellowStyle.Render(item.GetTier()) + } + }() + + lines := []string{"\n" + textStyle.Bold(true).Render(" "+item.GetName()) + starsStr + lastUpdStr} + badge := buildBadge(tierStr, item.GetStatus(), item.GetTransport(), dimStyle, labelStyle, cyanStyle) + if badge != "" { + lines = append(lines, " "+badge) + } + return lines +} + +// buildBadge joins tier/status/transport with "·" separators. +func buildBadge(tier, status, transport string, dimStyle, labelStyle, cyanStyle lipgloss.Style) string { + dot := dimStyle.Render(" · ") + var parts []string + if tier != "" { + parts = append(parts, tier) + } + if status != "" { + parts = append(parts, labelStyle.Render(status)) + } + if transport != "" { + parts = append(parts, cyanStyle.Render(transport)) + } + return strings.Join(parts, dot) +} + +// buildDetailServerType appends container image or remote URL section if present. +func buildDetailServerType( + item regtypes.ServerMetadata, + section func(string) string, + detailRow func(string, string) string, +) []string { + var lines []string + switch v := item.(type) { + case interface{ GetImage() string }: + if img := v.GetImage(); img != "" { + lines = append(lines, section("Container")) + lines = append(lines, detailRow("Image", img)) + } + case interface{ GetURL() string }: + if u := v.GetURL(); u != "" { + lines = append(lines, section("Endpoint")) + lines = append(lines, detailRow("URL", u)) + } + } + return lines +} + +// buildDetailTools appends the tools section (with descriptions if available). +func buildDetailTools( + item regtypes.ServerMetadata, + innerW int, + section func(string) string, + cyanStyle, labelStyle lipgloss.Style, +) []string { + const toolNameColW = 32 + var lines []string + if toolDefs := item.GetToolDefinitions(); len(toolDefs) > 0 { + lines = append(lines, section(fmt.Sprintf("Tools (%d)", len(toolDefs)))) + for _, t := range toolDefs { + nameRunes := []rune(t.Name) + if len(nameRunes) <= toolNameColW { + desc := runesTruncate(t.Description, innerW-toolNameColW-4) + lines = append(lines, " "+ui.PadToWidth(cyanStyle.Render(t.Name), toolNameColW+2)+labelStyle.Render(desc)) + } else { + // Name is long: put description on the next indented line. + lines = append(lines, " "+cyanStyle.Render(t.Name)) + if t.Description != "" { + lines = append(lines, " "+labelStyle.Render(runesTruncate(t.Description, innerW-6))) + } + } + } + } else if toolNames := item.GetTools(); len(toolNames) > 0 { + lines = append(lines, section(fmt.Sprintf("Tools (%d)", len(toolNames)))) + for _, t := range toolNames { + lines = append(lines, " "+cyanStyle.Render(t)) + } + } + return lines +} + +// renderRunFormOverlay renders the run-from-registry form overlay. +func (m Model) renderRunFormOverlay() string { + boxW, innerW, visibleRows := m.registryBoxDims() + item := m.runForm.item + + dimStyle := lipgloss.NewStyle().Foreground(ui.ColorDim) + titleStyle := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(ui.ColorDim2) + textStyle := lipgloss.NewStyle().Foreground(ui.ColorText) + greenStyle := lipgloss.NewStyle().Foreground(ui.ColorGreen) + sep := dimStyle.Render(strings.Repeat("─", innerW)) + + var sb strings.Builder + + // Breadcrumb header. + breadcrumb := titleStyle.Render("REGISTRY") + dimStyle.Render(" / ") + + textStyle.Bold(true).Render(item.GetName()) + dimStyle.Render(" / ") + + titleStyle.Render("RUN") + hint := "tab=next enter=run esc=cancel" + if m.runForm.running { + hint = "starting…" + } + sb.WriteString(breadcrumb + " " + hintStyle.Render(hint) + "\n") + sb.WriteString(sep + "\n") + + // Form fields (scrollable). + const linesPerField = 4 // label + desc + input + gap + const headerFooterLines = 5 + maxFields := max((visibleRows-headerFooterLines)/linesPerField, 2) + endIdx := min(m.runForm.scroll+maxFields, len(m.runForm.fields)) + + for i := m.runForm.scroll; i < endIdx; i++ { + f := m.runForm.fields[i] + focused := i == m.runForm.idx + lines := renderFormFieldFromStruct(f, focused, innerW) + for _, l := range lines { + sb.WriteString(l + "\n") + } + sb.WriteString("\n") + } + + if len(m.runForm.fields) > maxFields { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" field %d/%d", m.runForm.idx+1, len(m.runForm.fields))) + "\n") + } + + // Run button. + sb.WriteString(sep + "\n") + btnLabel := " ▶ Run " + item.GetName() + if m.runForm.running { + btnLabel = " ⟳ Starting…" + } + sb.WriteString(greenStyle.Bold(true).Render(btnLabel)) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). + Render(sb.String()), + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) +} + +// buildRunCmd builds a suggested `thv run` command string from registry metadata. +// Required env vars become --secret flags; optional ones are shown as comments. +// Non-default transport and permission profile are included when present. +// All dynamic values are single-quoted and escaped to prevent shell injection +// when the user pastes the copied command into a terminal. +func buildRunCmd(item regtypes.ServerMetadata) string { + const defaultTransport = "streamable-http" + + sq := func(s string) string { + return "'" + shellEscapeSingleQuote(s) + "'" + } + + var sb strings.Builder + sb.WriteString("thv run ") + sb.WriteString(sq(item.GetName())) + + // Transport only when non-default. + if t := item.GetTransport(); t != "" && t != defaultTransport { + sb.WriteString(" --transport ") + sb.WriteString(sq(t)) + } + + // Permission profile from ImageMetadata (Permissions is a direct field, not on the interface). + if img, ok := item.(*regtypes.ImageMetadata); ok && img != nil && img.Permissions != nil { + if name := img.Permissions.Name; name != "" && name != "none" { + sb.WriteString(" --permission-profile ") + sb.WriteString(sq(name)) + } + } + + // Required env vars → --secret (references a named secret already + // stored in the secrets manager); optional → comment line. + // Note: the run form uses --env for literal values entered by the user; + // this clipboard command is intended for users who manage secrets via + // `thv secret` and want a ready-to-paste shell invocation. + var optional []string + for _, ev := range item.GetEnvVars() { + if ev == nil { + continue + } + if ev.Required { + sb.WriteString(" --secret ") + sb.WriteString(sq(ev.Name)) + } else { + optional = append(optional, ev.Name) + } + } + for _, name := range optional { + sb.WriteString("\n# optional: --env ") + sb.WriteString(sq(name)) + sb.WriteString("=") + } + + return sb.String() +} diff --git a/pkg/tui/view_statusbar.go b/pkg/tui/view_statusbar.go new file mode 100644 index 0000000000..e12daccf5d --- /dev/null +++ b/pkg/tui/view_statusbar.go @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +// renderStatusBar renders the bottom 2-line help bar (separator + key hints). +// +//nolint:gocyclo // renders all status-bar states per panel; helper extraction done in separate funcs +func (m Model) renderStatusBar() string { + const statusBg = lipgloss.Color("#1e2030") + const badgeBg = lipgloss.Color("#2a2f45") + + // badge renders a key name with a contrasting background box. + // We use manual spaces instead of Padding to keep measurement predictable. + badge := func(k string) string { + return lipgloss.NewStyle(). + Background(badgeBg). + Foreground(ui.ColorText). + Render(" " + k + " ") + } + hint := func(k, desc string) string { + b := badge(k) + d := lipgloss.NewStyle().Foreground(ui.ColorDim2).Background(statusBg).Render(" " + desc) + return b + d + } + spacer := " " // plain spaces between hints (carry the statusBg from outer render) + + // Separator line — Width(m.width) ensures background fills the entire row. + sepLine := lipgloss.NewStyle(). + Width(m.width). + Background(statusBg). + Foreground(ui.ColorDim2). + Render(strings.Repeat("─", m.width)) + + // Confirmation prompt takes over the content line. + if m.confirmDelete { + if sel := m.selected(); sel != nil { + warn := lipgloss.NewStyle().Foreground(ui.ColorRed).Bold(true).Render("Delete " + sel.Name + "?") + info := lipgloss.NewStyle().Foreground(ui.ColorDim2).Render(" Press d to confirm, any other key to cancel") + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg).Render(" " + warn + info) + return sepLine + "\n" + contentLine + } + } + + // When log search prompt is open, show dedicated search hints. + if m.logSearchActive || m.proxyLogSearchActive { + parts := []string{ + hint("↵", "confirm"), + hint("n", "next"), + hint("N", "prev"), + hint("esc", "clear"), + } + hints := " " + strings.Join(parts, spacer) + gap := m.width - ui.VisibleLen(hints) + if gap < 1 { + gap = 1 + } + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg). + Render(hints + strings.Repeat(" ", gap)) + return sepLine + "\n" + contentLine + } + + // Context-sensitive hints based on active panel. + var parts []string + switch m.panel { + case panelInspector: + upDownDesc := func() string { + if m.insp.jsonRoot != nil { + return "tree" + } + if m.insp.result != "" { + return "scroll" + } + return "tool" + }() + parts = []string{ + hint("↑↓", upDownDesc), + hint("tab", "panel"), + hint("↵", "field/call"), + hint("y", "copy curl"), + hint("esc", "back"), + hint("q", "quit"), + } + if m.insp.filterActive { + parts = []string{ + hint("↑↓", "navigate"), + hint("↵", "confirm"), + hint("esc", "clear filter"), + } + } else if m.insp.jsonRoot != nil { + parts = []string{ + hint("↑↓", "tree"), + hint("space", "fold"), + hint("c", "copy JSON"), + hint("y", "copy curl"), + hint("/", "filter tools"), + hint("tab", "panel"), + hint("↵", "field/call"), + hint("esc", "back"), + hint("q", "quit"), + } + } else { + parts = append(parts, hint("i", "tool info")) + parts = append(parts, hint("/", "filter tools")) + } + case panelLogs: + parts = m.renderStatusBarLogHints(hint) + case panelProxyLogs: + parts = m.renderStatusBarProxyLogHints(hint) + case panelInfo, panelTools: + parts = renderStatusBarDefaultHints(hint) + } + + hints := " " + strings.Join(parts, spacer) + + // Notification — right-aligned, shown only when non-empty. + notif := "" + if m.notifMsg != "" { + notifColor := ui.ColorGreen + if !m.notifOK { + notifColor = ui.ColorRed + } + notif = lipgloss.NewStyle(). + Foreground(notifColor). + Background(statusBg). + Render(m.notifMsg + " ") + } + + // Pad hints to fill the gap so notif lands at the far right. + hintsLen := ui.VisibleLen(hints) + notifLen := ui.VisibleLen(notif) + gap := m.width - hintsLen - notifLen + if gap < 1 { + gap = 1 + } + content := hints + strings.Repeat(" ", gap) + notif + contentLine := lipgloss.NewStyle().Width(m.width).Background(statusBg).Render(content) + return sepLine + "\n" + contentLine +} + +// renderStatusBarDefaultHints returns the default status bar hints for panels +// that do not have specialized key bindings (Info, Tools). +func renderStatusBarDefaultHints(hint func(k, desc string) string) []string { + return []string{ + hint("↑↓", "navigate"), + hint("tab", "panel"), + hint("s", "stop"), + hint("r", "restart"), + hint("d", "delete"), + hint("u", "copy URL"), + hint("R", "registry"), + hint("/", "filter"), + hint("?", "help"), + hint("q", "quit"), + } +} + +// renderStatusBarLogHints returns the status bar hints for the Logs panel, +// switching to search-navigation hints when a search is active. +func (m Model) renderStatusBarLogHints(hint func(k, desc string) string) []string { + if m.logSearchQuery != "" { + return []string{ + hint("n", "next match"), + hint("N", "prev match"), + hint("esc", "clear search"), + hint("/", "new search"), + hint("q", "quit"), + } + } + return renderStatusBarDefaultHints(hint) +} + +// renderStatusBarProxyLogHints returns the status bar hints for the Proxy Logs panel, +// switching to search-navigation hints when a search is active. +func (m Model) renderStatusBarProxyLogHints(hint func(k, desc string) string) []string { + if m.proxyLogSearchQuery != "" { + return []string{ + hint("n", "next match"), + hint("N", "prev match"), + hint("esc", "clear search"), + hint("/", "new search"), + hint("q", "quit"), + } + } + return renderStatusBarDefaultHints(hint) +} + +// renderHelpOverlay renders the help modal centred on the terminal. +func (m Model) renderHelpOverlay() string { + helpContent := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.ColorPurple). + Padding(1, 3). + Width(60). + Render(helpText()) + + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + helpContent, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(ui.ColorDim), + ) + "\n(press any key to close)" +} + +func helpText() string { + bind := func(k, desc string) string { + key := lipgloss.NewStyle().Foreground(ui.ColorCyan).Render(fmt.Sprintf("%-16s", k)) + d := lipgloss.NewStyle().Foreground(ui.ColorText).Render(desc) + return key + d + } + heading := lipgloss.NewStyle().Foreground(ui.ColorPurple).Bold(true).Render + + lines := []string{ + heading("Navigation"), + bind("↑/k ↓/j", "select server"), + bind("tab", "switch panel (Logs/Info/Tools/Proxy/Inspector)"), + bind("/", "filter server list"), + "", + heading("Actions"), + bind("s", "stop selected server"), + bind("r", "restart selected server"), + bind("d d", "delete (press d twice to confirm)"), + bind("u", "copy server URL to clipboard"), + bind("R", "open registry browser"), + "", + heading("Logs panel"), + bind("f", "toggle follow mode"), + bind("/", "open inline search"), + bind("n / N", "next / previous search match"), + bind("esc", "clear search highlights"), + bind("← →", "scroll horizontally"), + "", + heading("Proxy Logs panel"), + bind("/", "open inline search"), + bind("n / N", "next / previous search match"), + bind("esc", "clear search highlights"), + bind("← →", "scroll horizontally"), + "", + heading("Inspector panel"), + bind("↑/↓", "navigate tools / JSON tree"), + bind("/", "filter tools by name"), + bind("↵", "call selected tool"), + bind("space", "collapse / expand JSON node"), + bind("c", "copy response to clipboard"), + bind("y", "copy curl command to clipboard"), + "", + heading("Other"), + bind("?", "toggle this help"), + bind("q / ctrl+c", "quit"), + } + return strings.Join(lines, "\n") +}