DEVELOPMENT ENVIRONMENT

~liljamo/tamma

8f9c9ff19483ee85459cf48e3bea226089fb9b84 — Jonni Liljamo a month ago b6eee0e 0.3.0
feat: optional confirm dialog, other rendering changes
6 files changed, 227 insertions(+), 52 deletions(-)

M README.md
A components/confirm/confirm.go
A components/confirm/keymap.go
M main.go
M styles/styles.go
M types/action.go
M README.md => README.md +5 -0
@@ 17,6 17,8 @@ See the example below, but in a nutshell:
  - `exectemplate` is a template for a command to be ran in a shell, it will be
    ran with `sh -c`. The template can access `.A.target.Name` and anything in
    `.A.target.Data`.
  - Optionally, you can provde `confirm: true` to have a dialog to confirm the
    command before running it.

### Example
```yaml


@@ 33,4 35,7 @@ targets:
actions:
  - name: ssh
    exectemplate: ssh {{ .A.target.Data.user }}@{{ .A.target.Data.host }}
  - name: ssh (confirm)
    exectemplate: ssh {{ .A.target.Data.user }}@{{ .A.target.Data.host }}
    confirm: true
```

A components/confirm/confirm.go => components/confirm/confirm.go +92 -0
@@ 0,0 1,92 @@
/*
 * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
 *
 * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for
 * more information.
 */

// Package confirm contains a confirm dialog component for Bubble Tea.
package confirm

import (
	"strings"

	"github.com/charmbracelet/bubbles/help"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

// Result is is a message containing the result of the confirm dialog.
type Result struct {
	Choice bool
}

// Model is the Bubble Tea model for this confirm dialog component.
type Model struct {
	keymap           *KeyMap
	help             help.Model
	dialogStyle      lipgloss.Style
	selectedYesStyle lipgloss.Style
	selectedNoStyle  lipgloss.Style
	choice           bool
}

// New returns a new Model.
func New() Model {
	return Model{
		keymap: NewKeyMap(),
		help:   help.New(),
		dialogStyle: lipgloss.NewStyle().
			AlignHorizontal(lipgloss.Center).
			PaddingLeft(4),
		selectedYesStyle: lipgloss.NewStyle().
			Foreground(lipgloss.Color("113")),
		selectedNoStyle: lipgloss.NewStyle().
			Foreground(lipgloss.Color("167")),
		choice: false,
	}
}

// Init is part of the tea.Model interface.
func (m Model) Init() tea.Cmd {
	return nil
}

// Update is part of the tea.Model interface.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case m.keymap.Submit.Keys()[0]:
			return m, func() tea.Msg {
				return Result{Choice: m.choice}
			}
		case m.keymap.Back.Keys()[0]:
			return m, func() tea.Msg {
				return Result{Choice: false}
			}
		case "left", "h", "right", "l":
			m.choice = !m.choice
		}
	}

	return m, nil
}

// View is part of the tea.Model interface.
func (m Model) View() string {
	s := strings.Builder{}
	dialog := strings.Builder{}
	dialog.WriteString("Confirm: ")
	if m.choice {
		dialog.WriteString(" no")
		dialog.WriteString(m.selectedYesStyle.Render(" >yes"))
	} else {
		dialog.WriteString(m.selectedNoStyle.Render(">no"))
		dialog.WriteString("  yes")
	}
	dialog.WriteString("\n")
	s.WriteString(m.dialogStyle.Render(dialog.String()))
	s.WriteString(m.help.View(m.keymap))
	return s.String()
}

A components/confirm/keymap.go => components/confirm/keymap.go +44 -0
@@ 0,0 1,44 @@
/*
 * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
 *
 * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for
 * more information.
 */

package confirm

import "github.com/charmbracelet/bubbles/key"

// KeyMap defines key bindings for the output viewport.
type KeyMap struct {
	Submit key.Binding
	Back   key.Binding
}

// NewKeyMap returns a new KeyMap.
func NewKeyMap() *KeyMap {
	return &KeyMap{
		Submit: key.NewBinding(
			key.WithKeys("enter"),
			key.WithHelp("enter", "submit"),
		),
		Back: key.NewBinding(
			key.WithKeys("q"),
			key.WithHelp("q", "back"),
		),
	}
}

// ShortHelp returns keybindings to be shown in the mini help view. It's part
// of the key.Map interface.
func (k KeyMap) ShortHelp() []key.Binding {
	return []key.Binding{k.Submit, k.Back}
}

// FullHelp returns keybindings for the expanded help view. It's part of the
// key.Map interface.
func (k KeyMap) FullHelp() [][]key.Binding {
	return [][]key.Binding{
		{k.Submit, k.Back},
	}
}

M main.go => main.go +76 -49
@@ 13,7 13,9 @@ import (
	"fmt"
	"os"
	"os/exec"
	"strings"

	"git.src.quest/~skye/tamma/components/confirm"
	"git.src.quest/~skye/tamma/styles"
	"git.src.quest/~skye/tamma/types"
	"github.com/charmbracelet/bubbles/help"


@@ 28,9 30,11 @@ import (
type state int

const (
	targetSelect state = iota
	actionSelect
	viewOutput
	stateTargetSelect state = iota
	stateActionSelect
	stateActionConfirm
	stateActionExec
	stateViewOutput
)

type model struct {


@@ 38,6 42,8 @@ type model struct {
	height             int
	err                string
	out                string
	exec               string
	confirmed          bool
	state              state
	targetList         list.Model
	selectedTarget     types.TargetItem


@@ 47,6 53,7 @@ type model struct {
	outputViewport     viewport.Model
	outputViewportKeys *types.OutputViewportKeyMap
	outputHelp         help.Model
	confirmDialog      confirm.Model
}

type execFinishedMsg struct {


@@ 70,43 77,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
			return m, tea.Quit
		case "enter":
			switch m.state {
			case targetSelect:
			case stateTargetSelect:
				i, ok := m.targetList.SelectedItem().(types.TargetItem)
				if ok {
					m.selectedTarget = i
					m.state = actionSelect
					m.state = stateActionSelect
				}
			case actionSelect:
			case stateActionSelect:
				a, ok := m.actionList.SelectedItem().(types.ActionItem)
				if ok {
					execString, err := a.ExecString(m.selectedTarget)
					exec, err := a.ExecString(m.selectedTarget)
					if err != nil {
						m.err = err.Error()
					} else {
						c := exec.Command("sh", "-c", execString)
						var out bytes.Buffer
						c.Stdout = &out
						return m, tea.ExecProcess(c, func(err error) tea.Msg {
							return execFinishedMsg{err, out}
						})
						m.exec = exec
						if a.Confirm {
							m.state = stateActionConfirm
							return m, nil
						}
						m.state = stateActionExec
					}
				}
			}
		case "q":
			switch m.state {
			case targetSelect:
			case stateTargetSelect:
				return m, tea.Quit
			case actionSelect:
			case stateActionSelect:
				if m.actionList.FilterState() != list.Filtering {
					m.actionList.ResetSelected()
					m.state = targetSelect
					m.state = stateTargetSelect
				}
			case viewOutput:
				m.state = actionSelect
			case stateViewOutput:
				m.state = stateActionSelect
			}
		case "c":
			switch m.state {
			case actionSelect:
			case stateActionSelect:
				if m.actionList.FilterState() != list.Filtering {
					if os.Getenv("WAYLAND_DISPLAY") != "" {
						a, ok := m.actionList.SelectedItem().(types.ActionItem)


@@ 126,8 133,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
			}
		case "o":
			switch m.state {
			case actionSelect:
				m.state = viewOutput
			case stateActionSelect:
				m.state = stateViewOutput
			}
		}
	case execFinishedMsg:


@@ 136,18 143,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
		if msg.err != nil {
			m.err = msg.err.Error()
		}
	case confirm.Result:
		if msg.Choice {
			m.state = stateActionExec
		} else {
			m.state = stateActionSelect
		}
	}

	switch m.state {
	case targetSelect:
	case stateTargetSelect:
		var cmd tea.Cmd
		m.targetList, cmd = m.targetList.Update(msg)
		return m, cmd
	case actionSelect:
	case stateActionSelect:
		var cmd tea.Cmd
		m.actionList, cmd = m.actionList.Update(msg)
		return m, cmd
	case viewOutput:
	case stateActionConfirm:
		var cmd tea.Cmd
		m.confirmDialog, cmd = m.confirmDialog.Update(msg)
		return m, cmd
	case stateActionExec:
		m.state = stateActionSelect
		c := exec.Command("sh", "-c", m.exec)
		var out bytes.Buffer
		c.Stdout = &out
		return m, tea.ExecProcess(c, func(err error) tea.Msg {
			return execFinishedMsg{err, out}
		})
	case stateViewOutput:
		var cmd tea.Cmd
		m.outputViewport, cmd = m.outputViewport.Update(msg)
		return m, cmd


@@ 157,43 182,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (m model) View() string {
	tlw := lipgloss.Width(styles.TopLevel.Render("")) - 2

	msg := "\n"
	s := strings.Builder{}
	switch m.state {
	case targetSelect:
		msg += m.targetList.View()
		msg += "\n"
	case actionSelect:
		alb := m.actionList.View()
		//alw := lipgloss.Width(alb)
		msg += alb
		msg += "\n"
		// NOTE: type asserting here will return nil if we've filtered the list to
		// have no results, thus this check is needed.
	case stateTargetSelect:
		s.WriteString(m.targetList.View())
	case stateActionSelect:
		s.WriteString(m.actionList.View())
		s.WriteString("\n")
		selectedAction, ok := m.actionList.SelectedItem().(types.ActionItem)
		if ok {
			execString, err := selectedAction.ExecString(m.selectedTarget)
			if err != nil {
				msg += fmt.Sprintf("error in ExecString(): %s\n", err.Error())
				s.WriteString("error in ExecString(): ")
				s.WriteString(err.Error())
			} else {
				s.WriteString(styles.ActionExecString.Render(execString))
			}
			msg += styles.ActionExecString.Width(tlw).SetString(execString).String()
		}
	case viewOutput:
	case stateActionConfirm:
		s.WriteString("Confirm that you want to run the following:\n\n")
		s.WriteString(styles.ActionExecString.Render(m.exec))
		s.WriteString("\n\n")
		s.WriteString(m.confirmDialog.View())
	case stateViewOutput:
		m.outputViewport.Height = m.height - 20
		m.outputViewport.Width = m.width - 2
		msg += m.outputViewport.View()
		msg += "\n\npager-like keys apply, plus the following:\n"
		msg += m.outputHelp.View(m.outputViewportKeys)
		msg += "\n"
		s.WriteString(m.outputViewport.View())
		s.WriteString("\n\npager-like keys apply, plus the following:\n")
		s.WriteString(m.outputHelp.View(m.outputViewportKeys))
	}

	if m.err != "" {
		msg += m.err
		s.WriteString("\n")
		s.WriteString(styles.Error.Render(m.err))
	}

	msg = styles.TopLevel.Render(msg)
	return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
	return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styles.TopLevel.Render(s.String()))
}

type config struct {


@@ 253,7 277,7 @@ func main() {
	actionListH := min(len(actionListItems), 10)
	actionListKeys := types.NewActionListKeyMap()
	actionList := list.New(actionListItems, types.ActionItemDelegate{}, 48, actionListH+4)
	actionList.Title = "What shall we do today?"
	actionList.Title = "Select action?"
	actionList.SetShowHelp(true)
	actionList.SetFilteringEnabled(true)
	actionList.SetShowStatusBar(false)


@@ 281,14 305,17 @@ func main() {
	outputViewportKeys := types.NewOutputViewportKeyMap()
	outputHelp := help.New()

	confirmDialog := confirm.New()

	p := tea.NewProgram(model{
		state:              targetSelect,
		state:              stateTargetSelect,
		targetList:         targetList,
		actionList:         actionList,
		actionListKeys:     actionListKeys,
		outputViewport:     outputViewport,
		outputViewportKeys: outputViewportKeys,
		outputHelp:         outputHelp,
		confirmDialog:      confirmDialog,
	}, tea.WithAltScreen())
	if _, err := p.Run(); err != nil {
		fmt.Printf("An error occured while running: %v\n", err)

M styles/styles.go => styles/styles.go +9 -3
@@ 1,5 1,5 @@
/*
 * Copyright (C) 2024 Jonni Liljamo <jonni@liljamo.com>
 * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
 *
 * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for
 * more information.


@@ 19,10 19,13 @@ import (

// nolint
var (
	AvailableWidth = 78

	TopLevel = lipgloss.NewStyle().
			BorderStyle(lipgloss.RoundedBorder()).
			BorderForeground(lipgloss.Color("059")).
			Width(78) // border takes 1 column each side
			Width(AvailableWidth).
			Padding(1)

	ListItem         = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("102"))
	ListItemSelected = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))


@@ 31,7 34,10 @@ var (

	ActionExecString = lipgloss.NewStyle().
				AlignHorizontal(lipgloss.Center).
				Foreground(lipgloss.Color("145"))
				Foreground(lipgloss.Color("145")).
				Width(AvailableWidth)

	ViewportStyle = lipgloss.NewStyle().MarginLeft(2)

	Error = lipgloss.NewStyle().Foreground(lipgloss.Color("167"))
)

M types/action.go => types/action.go +1 -0
@@ 23,6 23,7 @@ import (
type ActionItem struct {
	Name         string
	ExecTemplate string
	Confirm      bool
	A            map[string]any
}