package cli import ( "context" "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // livePluginSpec describes an invocation to rerun on a ticker. type livePluginSpec struct { Tool string Action string Selector string Params []string Interval time.Duration } // Display returns a short human label for the header. func (s livePluginSpec) Display() string { parts := []string{s.Tool} if s.Action != "" { parts = append(parts, s.Action) } parts = append(parts, s.Params...) out := strings.Join(parts, " ") if s.Selector != "" { out += " (@" + s.Selector + ")" } return out } // detectLivePluginInvocation returns a filled livePluginSpec if the parsed // argv is a plugin command (`kvm`, `lxc`, `lxd`, `bird`, `frr`) with a // `--live` flag. The flag is consumed so downstream execution sees a clean // argv without it. Optional `--interval=Ns` adjusts the refresh cadence. func detectLivePluginInvocation(args []string) (livePluginSpec, bool) { if len(args) == 0 { return livePluginSpec{}, false } tool := strings.ToLower(strings.TrimSpace(args[0])) switch tool { case "kvm", "lxc", "lxd", "bird", "frr": default: return livePluginSpec{}, false } hasLive := false interval := 3 * time.Second rest := args[1:] clean := make([]string, 0, len(rest)) for i := 0; i < len(rest); i++ { a := strings.TrimSpace(rest[i]) switch { case a == "--live", a == "-L": hasLive = true case a == "--interval": if i+1 < len(rest) { if d, err := time.ParseDuration(rest[i+1]); err == nil { interval = d } i++ } case strings.HasPrefix(a, "--interval="): if d, err := time.ParseDuration(strings.TrimPrefix(a, "--interval=")); err == nil { interval = d } default: clean = append(clean, rest[i]) } } if !hasLive { return livePluginSpec{}, false } // Resolve the cluster selector (--cluster/-c NAME) and pull it out of // the remaining args so action+params stay clean. selector, cleaned2, err := parseClusterSelectorArg(clean) if err != nil { return livePluginSpec{}, false } action := "" params := []string{} if len(cleaned2) > 0 { action = strings.ToLower(strings.TrimSpace(cleaned2[0])) params = cleaned2[1:] } if action == "" { switch tool { case "kvm", "lxc", "lxd": action = "list" default: action = "status" } } if tool == "kvm" && action == "top" { // kvm top is now an allocation/specs view, not a live telemetry stream. return livePluginSpec{}, false } if interval < 500*time.Millisecond { interval = 500 * time.Millisecond } return livePluginSpec{ Tool: tool, Action: action, Selector: selector, Params: params, Interval: interval, }, true } // startLiveCmd flips the model into fullscreen live-command mode and // schedules the first execution immediately. func (m monitorModel) startLiveCmd(spec livePluginSpec) (tea.Model, tea.Cmd) { m.liveCmdActive = true m.liveCmdSpec = spec m.liveCmdBuffer = "" m.liveCmdErr = "" m.liveCmdRunning = true m.liveCmdInterval = spec.Interval m.liveCmdScroll = 0 // Leave the pxmon console so the live view owns the screen. m.termMode = false m.termFull = false m.setStatus("live: " + spec.Display()) return m, m.runLiveCmdCmd(spec) } // liveCmdResultMsg carries one iteration of a live-command invocation. type liveCmdResultMsg struct { Spec livePluginSpec Output string Err string } type liveCmdTickMsg struct{} // runLiveCmdCmd kicks off one execution of the plugin command in a // goroutine. The result is delivered as liveCmdResultMsg and the caller // schedules the next tick once it lands. func (m monitorModel) runLiveCmdCmd(spec livePluginSpec) tea.Cmd { svc := m.svc return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), pluginActionTimeout(spec.Tool, spec.Action, true)) defer cancel() out, err := svc.RunPluginAction(ctx, spec.Selector, spec.Tool, spec.Action, spec.Params) res := liveCmdResultMsg{Spec: spec, Output: stripANSI(out)} if err != nil { res.Err = err.Error() } return res } } func liveCmdTickCmd(d time.Duration) tea.Cmd { if d <= 0 { d = 3 * time.Second } return tea.Tick(d, func(_ time.Time) tea.Msg { return liveCmdTickMsg{} }) } // handleLiveCmdKey routes keys while the live-command fullscreen is active. func (m monitorModel) handleLiveCmdKey(v tea.KeyMsg) (tea.Model, tea.Cmd) { switch v.String() { case "q", "esc", "ctrl+g", "ctrl+c": m.liveCmdActive = false m.liveCmdBuffer = "" m.liveCmdRunning = false m.setStatus("live: stopped") return m, nil case "r", "R": m.liveCmdRunning = true return m, m.runLiveCmdCmd(m.liveCmdSpec) case "+": if m.liveCmdInterval > time.Second { m.liveCmdInterval -= time.Second } m.liveCmdSpec.Interval = m.liveCmdInterval return m, nil case "-": m.liveCmdInterval += time.Second if m.liveCmdInterval > 60*time.Second { m.liveCmdInterval = 60 * time.Second } m.liveCmdSpec.Interval = m.liveCmdInterval return m, nil case "alt+up", "up", "k": m.liveCmdScroll += 3 return m, nil case "alt+down", "down", "j": m.liveCmdScroll -= 3 if m.liveCmdScroll < 0 { m.liveCmdScroll = 0 } return m, nil case "alt+shift+up": m.liveCmdScroll += 15 return m, nil case "alt+shift+down": m.liveCmdScroll -= 15 if m.liveCmdScroll < 0 { m.liveCmdScroll = 0 } return m, nil case "pgup": m.liveCmdScroll += 20 return m, nil case "pgdown": m.liveCmdScroll -= 20 if m.liveCmdScroll < 0 { m.liveCmdScroll = 0 } return m, nil case "home": return m, nil case "end": m.liveCmdScroll = 0 return m, nil case "alt+p": m.privacyMode = !m.privacyMode return m, nil } return m, nil } // renderLiveCmdView paints the fullscreen live command view. func (m monitorModel) renderLiveCmdView(width, height int) string { spec := m.liveCmdSpec title := accentStyle.Bold(true).Render(fmt.Sprintf(" live › %s ", spec.Display())) var ageText string if !m.liveCmdLastRun.IsZero() { ageText = fmt.Sprintf("updated %s ago", time.Since(m.liveCmdLastRun).Round(time.Millisecond)) } else { ageText = "pending…" } state := "idle" if m.liveCmdRunning { state = "running" } meta := dimStyle.Render(fmt.Sprintf( "interval %s · %s · %s", m.liveCmdInterval.Round(time.Second), state, ageText, )) help := dimStyle.Render("q/esc exit · r refresh · +/- interval · alt+↑/↓ scroll · alt+shift+↑/↓ fast · alt+p privacy") bodyLines := strings.Split(m.liveCmdBuffer, "\n") if m.liveCmdErr != "" { bodyLines = append([]string{critStyle.Render("error: " + m.liveCmdErr), ""}, bodyLines...) } if len(bodyLines) == 0 || (len(bodyLines) == 1 && bodyLines[0] == "") { bodyLines = []string{dimStyle.Render("(no output yet)")} } viewportH := height - 6 if viewportH < 5 { viewportH = 5 } maxOffset := len(bodyLines) - viewportH if maxOffset < 0 { maxOffset = 0 } if m.liveCmdScroll > maxOffset { m.liveCmdScroll = maxOffset } end := len(bodyLines) - m.liveCmdScroll start := end - viewportH if start < 0 { start = 0 } if end > len(bodyLines) { end = len(bodyLines) } windowed := bodyLines[start:end] panelW := max(24, width) bodyW := max(16, panelW-6) for i := range windowed { windowed[i] = truncateVisible(windowed[i], bodyW) } scrollBadge := "" if m.liveCmdScroll > 0 { scrollBadge = warnStyle.Render(fmt.Sprintf(" ↑ scrolled +%d (end to follow) ", m.liveCmdScroll)) } panel := lipgloss.NewStyle(). BorderStyle(thinBorder). BorderForeground(ccAccent). Padding(0, 1). Width(panelW). MaxWidth(panelW). Render(strings.Join(windowed, "\n")) headerLine := title + " " + meta if scrollBadge != "" { headerLine += " " + scrollBadge } headerLine = truncateVisible(headerLine, panelW) help = truncateVisible(help, panelW) return lipgloss.JoinVertical(lipgloss.Left, headerLine, help, panel) }