package cmd import ( "context" "fmt" "os" "os/exec" "strings" "time" "git.abrell.se/victor/mydev/internal/checks" "git.abrell.se/victor/mydev/internal/config" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" "github.com/muesli/reflow/wrap" "github.com/spf13/cobra" ) var dashInterval int var dashCmd = &cobra.Command{ Use: "dash", Short: "Live refreshing TUI dashboard", Run: func(cmd *cobra.Command, args []string) { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } awsmfa, _ := cfg.AWSMFA() // empty if dev_dir unset; panel shows "not configured" composeFile, _ := cfg.ComposeFile() // empty if dev_dir unset; drift check skipped clearTerminal() // start clean (no prior scrollback) p := tea.NewProgram(newDash(cfg.ReposRoots, awsmfa, composeFile, time.Duration(dashInterval)*time.Second), tea.WithAltScreen()) _, err = p.Run() clearTerminal() // leave clean on exit (hide any interactive-command output) if err != nil { fmt.Println(err) } }, } func init() { dashCmd.Flags().IntVar(&dashInterval, "interval", 10, "refresh interval in seconds") rootCmd.AddCommand(dashCmd) } // ---- messages emitted by background fetches ---- type authMsg checks.AuthStatus type dockerMsg struct { list []checks.Container err string } type driftMsg struct { stale int total int err string } type repoMsg struct { list []checks.Repo err string } type tickMsg time.Time type execDoneMsg struct{ err error } // cmdResultMsg carries the captured output of a non-interactive command. type cmdResultMsg struct { title string body string err error } // pendingAction is a command awaiting y/n confirmation before it runs. type pendingAction struct { label string // shown in the confirm prompt args []string // args passed to this binary (e.g. ["dev","restart"]) } // ---- model ---- type dashModel struct { reposRoots []string awsmfa string composeFile string interval time.Duration self string // path to this binary, for re-invoking subcommands auth checks.AuthStatus authChecked bool // true once the first auth result has arrived docker dockerMsg drift driftMsg repos repoMsg pending *pendingAction // non-nil while awaiting y/n confirmation cmdline textinput.Model cmdActive bool // true while the command palette is open paletteNote string // transient message shown in the palette (e.g. blocked cmd) running string // label of a captured command currently executing cancel context.CancelFunc // cancels the running captured command cancelled bool // set when the user cancelled the running command result *cmdResultMsg viewport viewport.Model lastRefresh time.Time width int height int } func newDash(reposRoots []string, awsmfa, composeFile string, interval time.Duration) dashModel { if interval <= 0 { interval = 10 * time.Second } self, err := os.Executable() if err != nil { self = os.Args[0] } ti := textinput.New() ti.Prompt = "mydev ❯ " ti.Placeholder = "data table db grant port 3306 …" return dashModel{reposRoots: reposRoots, awsmfa: awsmfa, composeFile: composeFile, interval: interval, self: self, cmdline: ti} } // interactive commands need the live terminal (stdin prompts / long log streams) // so they run via ExecProcess; everything else is captured into a result pane. func interactive(args []string) bool { switch { case len(args) >= 2 && args[0] == "auth" && args[1] == "login": return true case len(args) >= 2 && args[0] == "dev" && (args[1] == "start" || args[1] == "restart"): return true case len(args) >= 1 && args[0] == "data": // Prod-data fetches may prompt and stream progress — run them live. return true } return false } // launch picks the execution mode for a command and returns the updated model // plus the command to run. func (m dashModel) launch(args []string) (dashModel, tea.Cmd) { if interactive(args) { return m, m.runSelf(args...) } ctx, cancel := context.WithCancel(context.Background()) m.running = strings.Join(args, " ") m.cancel = cancel m.cancelled = false return m, m.runCaptured(ctx, args...) } // clearTerminal wipes the screen and scrollback so the dashboard starts and // exits without leftover command output showing in the normal buffer. func clearTerminal() { fmt.Print("\033[2J\033[3J\033[H") } // runSelf suspends the TUI, runs `this-binary ` with full terminal // control (so MFA prompts and compose logs work), then resumes and refreshes. // It clears the revealed terminal (screen + scrollback) first so the user isn't // looking at leftover output behind the interactive command. The args go // through "$@" so spaces need no quoting. func (m dashModel) runSelf(args ...string) tea.Cmd { shArgs := append([]string{"-c", `printf '\033[2J\033[3J\033[H'; exec "$0" "$@"`, m.self}, args...) c := exec.Command("sh", shArgs...) return tea.ExecProcess(c, func(err error) tea.Msg { return execDoneMsg{err} }) } // wrapText fits text to width w: word-wrap at spaces first, then hard-wrap any // remaining over-long tokens (e.g. a generated password with no spaces) so // nothing overflows the result pane. func wrapText(s string, w int) string { if w <= 0 { return s } return wrap.String(wordwrap.String(s, w), w) } // showResult stores the captured output and loads it into the viewport, wrapped // to the current width. func (m *dashModel) showResult(r cmdResultMsg) { m.result = &r m.viewport.SetContent(wrapText(r.body, m.viewport.Width)) m.viewport.GotoTop() } // runCaptured runs the command in the background, capturing its output to show // in the result pane. The dashboard stays visible (with a "running" note) until // the result arrives. func (m dashModel) runCaptured(ctx context.Context, args ...string) tea.Cmd { self, title := m.self, strings.Join(args, " ") return func() tea.Msg { out, err := exec.CommandContext(ctx, self, args...).CombinedOutput() return cmdResultMsg{title: title, body: string(out), err: err} } } func (m dashModel) refresh() tea.Cmd { return tea.Batch(fetchAuth(m.awsmfa), fetchDocker, fetchDrift(m.composeFile), fetchRepos(m.reposRoots)) } func (m dashModel) Init() tea.Cmd { return tea.Batch(m.refresh(), tick(m.interval)) } func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: k := msg.String() // Result pane open: esc/q/enter closes it; other keys scroll. if m.result != nil { switch k { case "esc", "q", "enter": m.result = nil return m, nil case "ctrl+c": return m, tea.Quit } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd } // Command palette open: route typing to the input; Enter runs it. if m.cmdActive { switch k { case "enter": args := strings.Fields(m.cmdline.Value()) if len(args) > 0 && dashboardCovered(args) { // Already on the dashboard — refuse, keep palette open. m.cmdline.Reset() m.paletteNote = strings.Join(args, " ") + " is already shown on the dashboard" return m, nil } m.cmdActive = false m.cmdline.Blur() m.cmdline.Reset() m.paletteNote = "" if len(args) > 0 { return m.launch(args) } return m, nil case "esc", "ctrl+c": m.cmdActive = false m.cmdline.Blur() m.cmdline.Reset() m.paletteNote = "" return m, nil case "tab": if _, items, leaf := completions(m.cmdline.Value()); !leaf && len(items) > 0 { toks := strings.Fields(m.cmdline.Value()) if !strings.HasSuffix(m.cmdline.Value(), " ") && len(toks) > 0 { toks = toks[:len(toks)-1] } toks = append(toks, items[0].name) m.cmdline.SetValue(strings.Join(toks, " ") + " ") m.cmdline.CursorEnd() } return m, nil } m.paletteNote = "" // clear once the user edits the input var cmd tea.Cmd m.cmdline, cmd = m.cmdline.Update(msg) return m, cmd } // While a confirm prompt is up, only y/n/esc are meaningful. if m.pending != nil { switch k { case "y", "Y": p := m.pending m.pending = nil return m.launch(p.args) case "n", "N", "esc", "q": m.pending = nil } return m, nil } // A captured command is running: block new launches; allow cancel/quit. if m.running != "" { switch k { case "c", "C": if m.cancel != nil { m.cancelled = true m.cancel() } case "ctrl+c", "q": return m, tea.Quit } return m, nil } switch k { case "q", "ctrl+c": return m, tea.Quit case "r": // manual refresh return m, m.refresh() case "a": m.pending = &pendingAction{"auth login", []string{"auth", "login"}} case "s": m.pending = &pendingAction{"dev start", []string{"dev", "start"}} case "x": m.pending = &pendingAction{"dev stop", []string{"dev", "stop"}} case "R": m.pending = &pendingAction{"dev restart", []string{"dev", "restart"}} case ":": m.cmdActive = true m.paletteNote = "" return m, m.cmdline.Focus() } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.cmdline.Width = msg.Width - len(m.cmdline.Prompt) - 2 // Result pane leaves room for a title line and a help line. m.viewport.Width = msg.Width m.viewport.Height = msg.Height - 3 // Re-wrap any visible result to the new width. if m.result != nil { m.viewport.SetContent(wrapText(m.result.body, m.viewport.Width)) } case authMsg: m.auth = checks.AuthStatus(msg) m.authChecked = true m.lastRefresh = time.Now() case dockerMsg: m.docker = msg case driftMsg: m.drift = msg case repoMsg: m.repos = msg case tickMsg: // periodic refresh + reschedule return m, tea.Batch(m.refresh(), tick(m.interval)) case execDoneMsg: // An interactive command finished and the TUI has resumed — refresh. return m, m.refresh() case cmdResultMsg: m.running = "" m.cancel = nil // Cancelled by the user: just return to the menu, no result pane. if m.cancelled { m.cancelled = false return m, m.refresh() } // Otherwise show the output in the result pane. body := msg.body if msg.err != nil { body = strings.TrimRight(body, "\n") + "\n\n[exit: " + msg.err.Error() + "]" } if strings.TrimSpace(body) == "" { body = "(no output)" } r := msg r.body = body m.showResult(r) return m, m.refresh() } return m, nil } // ---- commands (run in background, return a msg) ---- func fetchAuth(awsmfa string) tea.Cmd { return func() tea.Msg { if awsmfa == "" { return authMsg{Err: "dev_dir not set in config"} } return authMsg(checks.AWSAuth(context.Background(), awsmfa)) } } func fetchDocker() tea.Msg { list, err := checks.Containers(context.Background()) return dockerMsg{list: list, err: err} } func fetchDrift(composeFile string) tea.Cmd { return func() tea.Msg { if composeFile == "" { return driftMsg{} } drift, err := checks.ComposeDrift(context.Background(), composeFile) if err != "" { return driftMsg{err: err} } stale := 0 for _, d := range drift { if !d.UpToDate() { stale++ } } return driftMsg{stale: stale, total: len(drift)} } } func fetchRepos(roots []string) tea.Cmd { return func() tea.Msg { list, err := checks.ReposMulti(context.Background(), roots) return repoMsg{list: list, err: err} } } func tick(d time.Duration) tea.Cmd { return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) } // ---- styles ---- var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).MarginBottom(1) headerStyle = lipgloss.NewStyle().Bold(true).Underline(true) ) func (m dashModel) View() string { // Result pane takes over the whole screen until dismissed. if m.result != nil { head := titleStyle.Render("$ mydev "+m.result.title) + "\n" help := "\n" + dimStyle.Render("↑/↓ scroll · esc/enter close") return head + m.viewport.View() + help } var b strings.Builder status := fmt.Sprintf("refreshed %s · every %s", m.lastRefresh.Format("15:04:05"), m.interval) if m.running != "" { status += " · " + okStyle.Render("running "+m.running+"…") } b.WriteString(titleStyle.Render("MyDev") + " " + dimStyle.Render(status) + "\n\n") b.WriteString(panelStyle.Render(m.authPanel()) + "\n") b.WriteString(panelStyle.Render(m.dockerPanel()) + "\n") b.WriteString(panelStyle.Render(m.repoPanel()) + "\n") b.WriteString(m.footer()) return b.String() } type suggestion struct { name string short string } // completions introspects the cobra command tree for the current palette input. // It descends through completed tokens, then returns the candidates for the // next token. leaf is true when the matched command takes args rather than // subcommands — then items holds a single usage hint. func completions(input string) (label string, items []suggestion, leaf bool) { tokens := strings.Fields(input) // A trailing space (or empty input) means we're picking the *next* token, // so there is no partial filter on the final token. var partial string completed := tokens if !strings.HasSuffix(input, " ") && input != "" && len(tokens) > 0 { partial = tokens[len(tokens)-1] completed = tokens[:len(tokens)-1] } cur := rootCmd for _, tok := range completed { next := childNamed(cur, tok) if next == nil { return strings.Join(completed, " "), nil, false } cur = next } var children []suggestion for _, c := range cur.Commands() { switch c.Name() { case "help", "completion", "dash": // not useful from inside the dash continue } // Hide root commands already covered by the dashboard. `status` is shown // across the panels; bare `auth` is just the AWS panel (and `auth login` // has the `a` quick key + is reachable by typing `auth `). // `dev status` is kept — more detail than the drift badge. if cur == rootCmd && (c.Name() == "status" || c.Name() == "auth") { continue } if c.Hidden { continue } if partial == "" || strings.HasPrefix(c.Name(), partial) { children = append(children, suggestion{c.Name(), c.Short}) } } if len(cur.Commands()) == 0 { // Leaf command: show how to call it. return cur.CommandPath(), []suggestion{{cur.Use, cur.Short}}, true } label = "commands" if cur != rootCmd { label = cur.CommandPath() } return label, children, false } // dashboardCovered reports commands whose output is already on the dashboard, // so the palette hides them and refuses to run them. `auth login` is allowed; // only the bare `auth` status is blocked. func dashboardCovered(args []string) bool { if len(args) == 0 { return false } switch { case args[0] == "status": return true case args[0] == "auth" && len(args) == 1: return true } return false } func childNamed(parent *cobra.Command, name string) *cobra.Command { for _, c := range parent.Commands() { if c.Name() == name { return c } for _, a := range c.Aliases { if a == name { return c } } } return nil } // footer shows the command palette when open, the confirm prompt when an action // is pending, otherwise the key legend. func (m dashModel) footer() string { if m.cmdActive { var b strings.Builder b.WriteString(m.cmdline.View() + "\n") if m.paletteNote != "" { b.WriteString(badStyle.Render(m.paletteNote) + "\n") } label, items, leaf := completions(m.cmdline.Value()) switch { case leaf: it := items[0] line := "usage: " + it.name if it.short != "" { line += " — " + it.short } b.WriteString(dimStyle.Render(line) + "\n") case len(items) == 0: b.WriteString(dimStyle.Render("no matching commands") + "\n") default: b.WriteString(dimStyle.Render(label+":") + "\n") const max = 8 for i, it := range items { if i >= max { b.WriteString(dimStyle.Render(fmt.Sprintf(" … +%d more", len(items)-max)) + "\n") break } b.WriteString(dimStyle.Render(fmt.Sprintf(" %-10s %s", it.name, it.short)) + "\n") } } b.WriteString(dimStyle.Render("tab complete · enter run · esc cancel")) return b.String() } if m.running != "" { return okStyle.Render("running "+m.running+"…") + dimStyle.Render(" [c]ancel") } if m.pending != nil { return badStyle.Render("Run '"+m.pending.label+"'?") + dimStyle.Render(" [y] yes [n] no") } return dimStyle.Render("[r]efresh [a]uth [s]tart [x]stop [R]estart [:]command [q]uit") } func (m dashModel) authPanel() string { var s strings.Builder s.WriteString(headerStyle.Render("AWS AUTH") + "\n") switch { case !m.authChecked: s.WriteString(dimStyle.Render("checking…")) case m.auth.Authed: mark := okStyle.Render("✓ authed") // Warn when the session expires within 30 min. if !m.auth.Expires.IsZero() && time.Until(m.auth.Expires) < 30*time.Minute { mark = badStyle.Render("⚠ expiring") } s.WriteString(mark + dimStyle.Render(" "+m.auth.Detail)) default: // Not authed: awsmfa errored, or the session is gone/expired. detail := m.auth.Err if detail == "" { detail = m.auth.Detail } s.WriteString(badStyle.Render("✗ re-auth needed") + "\n" + dimStyle.Render(truncate(detail, 70))) } return s.String() } func (m dashModel) dockerPanel() string { var s strings.Builder s.WriteString(headerStyle.Render("DOCKER") + m.driftBadge() + "\n") if m.docker.err != "" { s.WriteString(badStyle.Render("✗ " + truncate(m.docker.err, 70))) return s.String() } if len(m.docker.list) == 0 { s.WriteString(dimStyle.Render("no containers")) return s.String() } for _, c := range m.docker.list { mark := badStyle.Render("●") if c.State == "running" { mark = okStyle.Render("●") } s.WriteString(fmt.Sprintf("%s %-20s %s\n", mark, c.Name, dimStyle.Render(c.Status))) } return strings.TrimRight(s.String(), "\n") } // driftBadge summarizes dev-repo container freshness next to the DOCKER header. func (m dashModel) driftBadge() string { switch { case m.composeFile == "": return "" case m.drift.err != "": return " " + dimStyle.Render("(drift: "+truncate(m.drift.err, 30)+")") case m.drift.total == 0: return " " + dimStyle.Render("(checking…)") case m.drift.stale > 0: return " " + badStyle.Render(fmt.Sprintf("⚠ %d/%d stale — dev restart", m.drift.stale, m.drift.total)) default: return " " + okStyle.Render(fmt.Sprintf("✓ all %d current", m.drift.total)) } } func (m dashModel) repoPanel() string { var s strings.Builder s.WriteString(headerStyle.Render("REPOS") + "\n") if m.repos.err != "" { s.WriteString(badStyle.Render("✗ " + truncate(m.repos.err, 70))) return s.String() } if len(m.repos.list) == 0 { s.WriteString(dimStyle.Render("none found")) return s.String() } for _, r := range m.repos.list { state := okStyle.Render("clean") if r.Dirty { state = badStyle.Render("dirty") } if r.Err != "" { state = badStyle.Render("err") } s.WriteString(fmt.Sprintf("%-20s %s %s\n", r.Name, dimStyle.Render(r.Branch), state)) } return strings.TrimRight(s.String(), "\n") } func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") if len(s) > n { return s[:n] + "…" } return s }