From 5b6bf1f0aedcca779261849b2b38d352b6457255 Mon Sep 17 00:00:00 2001
From: Leon Mika <lmika@lmika.org>
Date: Thu, 18 Aug 2022 21:39:13 +1000
Subject: [PATCH] ctrlret: replaced return types of controllers from tea.Cmd to
 tea.Msg

This dramatically cuts downs the number of closures.
---
 cmd/ssm-browse/main.go                        |   2 +-
 internal/common/ui/commandctrl/commandctrl.go |  22 +-
 .../common/ui/commandctrl/commandctrl_test.go |   4 +-
 internal/common/ui/commandctrl/types.go       |   8 +-
 internal/common/ui/events/commands.go         |  30 +-
 internal/common/ui/events/errors.go           |   2 +-
 .../dynamo-browse/controllers/commands.go     |   4 +-
 internal/dynamo-browse/controllers/events.go  |   2 +-
 .../dynamo-browse/controllers/tableread.go    | 248 ++++----
 .../controllers/tableread_test.go             |  34 +-
 .../dynamo-browse/controllers/tablewrite.go   | 564 ++++++++----------
 internal/dynamo-browse/ui/model.go            |  42 +-
 .../ui/teamodels/statusandprompt/model.go     |   2 +-
 .../ui/teamodels/tableselect/model.go         |   2 +-
 internal/slog-view/ui/model.go                |   2 +-
 .../ssm-browse/controllers/ssmcontroller.go   |  56 +-
 internal/ssm-browse/ui/model.go               |  10 +-
 17 files changed, 472 insertions(+), 562 deletions(-)

diff --git a/cmd/ssm-browse/main.go b/cmd/ssm-browse/main.go
index a09ec07..c5e46c9 100644
--- a/cmd/ssm-browse/main.go
+++ b/cmd/ssm-browse/main.go
@@ -50,7 +50,7 @@ func main() {
 	cmdController := commandctrl.NewCommandController()
 	cmdController.AddCommands(&commandctrl.CommandContext{
 		Commands: map[string]commandctrl.Command{
-			"cd": func(args []string) tea.Cmd {
+			"cd": func(args []string) tea.Msg {
 				return ctrl.ChangePrefix(args[0])
 			},
 		},
diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go
index e5c84e3..f26ab6c 100644
--- a/internal/common/ui/commandctrl/commandctrl.go
+++ b/internal/common/ui/commandctrl/commandctrl.go
@@ -25,18 +25,16 @@ func (c *CommandController) AddCommands(ctx *CommandContext) {
 	c.commandList = ctx
 }
 
-func (c *CommandController) Prompt() tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: ":",
-			OnDone: func(value string) tea.Cmd {
-				return c.Execute(value)
-			},
-		}
+func (c *CommandController) Prompt() tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: ":",
+		OnDone: func(value string) tea.Msg {
+			return c.Execute(value)
+		},
 	}
 }
 
-func (c *CommandController) Execute(commandInput string) tea.Cmd {
+func (c *CommandController) Execute(commandInput string) tea.Msg {
 	input := strings.TrimSpace(commandInput)
 	if input == "" {
 		return nil
@@ -46,18 +44,18 @@ func (c *CommandController) Execute(commandInput string) tea.Cmd {
 	command := c.lookupCommand(tokens[0])
 	if command == nil {
 		log.Println("No such command: ", tokens)
-		return events.SetError(errors.New("no such command: " + tokens[0]))
+		return events.Error(errors.New("no such command: " + tokens[0]))
 	}
 
 	return command(tokens[1:])
 }
 
 func (c *CommandController) Alias(commandName string) Command {
-	return func(args []string) tea.Cmd {
+	return func(args []string) tea.Msg {
 		command := c.lookupCommand(commandName)
 		if command == nil {
 			log.Println("No such command: ", commandName)
-			return events.SetError(errors.New("no such command: " + commandName))
+			return events.Error(errors.New("no such command: " + commandName))
 		}
 
 		return command(args)
diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go
index 0598621..e842c39 100644
--- a/internal/common/ui/commandctrl/commandctrl_test.go
+++ b/internal/common/ui/commandctrl/commandctrl_test.go
@@ -1,10 +1,10 @@
 package commandctrl_test
 
 import (
+	"github.com/lmika/audax/internal/common/ui/events"
 	"testing"
 
 	"github.com/lmika/audax/internal/common/ui/commandctrl"
-	"github.com/lmika/audax/internal/common/ui/events"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -12,7 +12,7 @@ func TestCommandController_Prompt(t *testing.T) {
 	t.Run("prompt user for a command", func(t *testing.T) {
 		cmd := commandctrl.NewCommandController()
 
-		res := cmd.Prompt()()
+		res := cmd.Prompt()
 
 		promptForInputMsg, ok := res.(events.PromptForInputMsg)
 		assert.True(t, ok)
diff --git a/internal/common/ui/commandctrl/types.go b/internal/common/ui/commandctrl/types.go
index 0cf1a9e..c5f6f74 100644
--- a/internal/common/ui/commandctrl/types.go
+++ b/internal/common/ui/commandctrl/types.go
@@ -2,16 +2,16 @@ package commandctrl
 
 import tea "github.com/charmbracelet/bubbletea"
 
-type Command func(args []string) tea.Cmd
+type Command func(args []string) tea.Msg
 
 func NoArgCommand(cmd tea.Cmd) Command {
-	return func(args []string) tea.Cmd {
-		return cmd
+	return func(args []string) tea.Msg {
+		return cmd()
 	}
 }
 
 type CommandContext struct {
 	Commands map[string]Command
 
-	parent   *CommandContext
+	parent *CommandContext
 }
diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go
index ef82c2f..8aca1a7 100644
--- a/internal/common/ui/events/commands.go
+++ b/internal/common/ui/events/commands.go
@@ -10,29 +10,19 @@ func Error(err error) tea.Msg {
 	return ErrorMsg(err)
 }
 
-func SetError(err error) tea.Cmd {
-	return func() tea.Msg {
-		return Error(err)
+func SetStatus(msg string) tea.Msg {
+	return StatusMsg(msg)
+}
+
+func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg {
+	return PromptForInputMsg{
+		Prompt: prompt,
+		OnDone: onDone,
 	}
 }
 
-func SetStatus(msg string) tea.Cmd {
-	return func() tea.Msg {
-		return StatusMsg(msg)
-	}
-}
-
-func PromptForInput(prompt string, onDone func(value string) tea.Cmd) tea.Cmd {
-	return func() tea.Msg {
-		return PromptForInputMsg{
-			Prompt: prompt,
-			OnDone: onDone,
-		}
-	}
-}
-
-func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd {
-	return PromptForInput(prompt, func(value string) tea.Cmd {
+func Confirm(prompt string, onYes func() tea.Msg) tea.Msg {
+	return PromptForInput(prompt, func(value string) tea.Msg {
 		if value == "y" {
 			return onYes()
 		}
diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go
index 9aa3388..fd05570 100644
--- a/internal/common/ui/events/errors.go
+++ b/internal/common/ui/events/errors.go
@@ -16,5 +16,5 @@ type ModeMessage string
 // PromptForInput indicates that the context is requesting a line of input
 type PromptForInputMsg struct {
 	Prompt string
-	OnDone func(value string) tea.Cmd
+	OnDone func(value string) tea.Msg
 }
diff --git a/internal/dynamo-browse/controllers/commands.go b/internal/dynamo-browse/controllers/commands.go
index dd94a93..16390e2 100644
--- a/internal/dynamo-browse/controllers/commands.go
+++ b/internal/dynamo-browse/controllers/commands.go
@@ -15,9 +15,9 @@ func (ps *promptSequence) next() tea.Msg {
 	if len(ps.receivedValues) < len(ps.prompts) {
 		return events.PromptForInputMsg{
 			Prompt: ps.prompts[len(ps.receivedValues)],
-			OnDone: func(value string) tea.Cmd {
+			OnDone: func(value string) tea.Msg {
 				ps.receivedValues = append(ps.receivedValues, value)
-				return ps.next
+				return ps.next()
 			},
 		}
 	}
diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go
index cf957b1..63c79a6 100644
--- a/internal/dynamo-browse/controllers/events.go
+++ b/internal/dynamo-browse/controllers/events.go
@@ -46,7 +46,7 @@ type SetReadWrite struct {
 
 type PromptForTableMsg struct {
 	Tables     []string
-	OnSelected func(tableName string) tea.Cmd
+	OnSelected func(tableName string) tea.Msg
 }
 
 type ResultSetUpdated struct {
diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go
index a5caa5a..62a5777 100644
--- a/internal/dynamo-browse/controllers/tableread.go
+++ b/internal/dynamo-browse/controllers/tableread.go
@@ -35,7 +35,7 @@ func NewTableReadController(state *State, tableService TableReadService, workspa
 }
 
 // Init does an initial scan of the table.  If no table is specified, it prompts for a table, then does a scan.
-func (c *TableReadController) Init() tea.Cmd {
+func (c *TableReadController) Init() tea.Msg {
 	if c.tableName == "" {
 		return c.ListTables()
 	} else {
@@ -43,51 +43,43 @@ func (c *TableReadController) Init() tea.Cmd {
 	}
 }
 
-func (c *TableReadController) ListTables() tea.Cmd {
-	return func() tea.Msg {
-		tables, err := c.tableService.ListTables(context.Background())
-		if err != nil {
-			return events.Error(err)
-		}
+func (c *TableReadController) ListTables() tea.Msg {
+	tables, err := c.tableService.ListTables(context.Background())
+	if err != nil {
+		return events.Error(err)
+	}
 
-		return PromptForTableMsg{
-			Tables: tables,
-			OnSelected: func(tableName string) tea.Cmd {
-				return c.ScanTable(tableName)
-			},
-		}
+	return PromptForTableMsg{
+		Tables: tables,
+		OnSelected: func(tableName string) tea.Msg {
+			return c.ScanTable(tableName)
+		},
 	}
 }
 
-func (c *TableReadController) ScanTable(name string) tea.Cmd {
-	return func() tea.Msg {
-		ctx := context.Background()
+func (c *TableReadController) ScanTable(name string) tea.Msg {
+	ctx := context.Background()
 
-		tableInfo, err := c.tableService.Describe(ctx, name)
-		if err != nil {
-			return events.Error(errors.Wrapf(err, "cannot describe %v", c.tableName))
-		}
-
-		resultSet, err := c.tableService.Scan(ctx, tableInfo)
-		if err != nil {
-			return events.Error(err)
-		}
-		resultSet = c.tableService.Filter(resultSet, c.state.Filter())
-
-		return c.setResultSetAndFilter(resultSet, c.state.Filter(), true)
+	tableInfo, err := c.tableService.Describe(ctx, name)
+	if err != nil {
+		return events.Error(errors.Wrapf(err, "cannot describe %v", c.tableName))
 	}
+
+	resultSet, err := c.tableService.Scan(ctx, tableInfo)
+	if err != nil {
+		return events.Error(err)
+	}
+	resultSet = c.tableService.Filter(resultSet, c.state.Filter())
+
+	return c.setResultSetAndFilter(resultSet, c.state.Filter(), true)
 }
 
-func (c *TableReadController) PromptForQuery() tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: "query: ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					return c.runQuery(c.state.ResultSet().TableInfo, value, "", true)
-				}
-			},
-		}
+func (c *TableReadController) PromptForQuery() tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: "query: ",
+		OnDone: func(value string) tea.Msg {
+			return c.runQuery(c.state.ResultSet().TableInfo, value, "", true)
+		},
 	}
 }
 
@@ -107,7 +99,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
 
 	expr, err := queryexpr.Parse(query)
 	if err != nil {
-		return events.SetError(err)
+		return events.Error(err)
 	}
 
 	return c.doIfNoneDirty(func() tea.Msg {
@@ -135,58 +127,54 @@ func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg {
 
 	return events.PromptForInputMsg{
 		Prompt: "reset modified items? ",
-		OnDone: func(value string) tea.Cmd {
+		OnDone: func(value string) tea.Msg {
 			if value != "y" {
 				return events.SetStatus("operation aborted")
 			}
 
-			return cmd
+			return cmd()
 		},
 	}
 }
 
-func (c *TableReadController) Rescan() tea.Cmd {
-	return func() tea.Msg {
-		return c.doIfNoneDirty(func() tea.Msg {
-			resultSet := c.state.ResultSet()
-			return c.doScan(context.Background(), resultSet, resultSet.Query, true)
-		})
-	}
+func (c *TableReadController) Rescan() tea.Msg {
+	return c.doIfNoneDirty(func() tea.Msg {
+		resultSet := c.state.ResultSet()
+		return c.doScan(context.Background(), resultSet, resultSet.Query, true)
+	})
 }
 
-func (c *TableReadController) ExportCSV(filename string) tea.Cmd {
-	return func() tea.Msg {
-		resultSet := c.state.ResultSet()
-		if resultSet == nil {
-			return events.Error(errors.New("no result set"))
-		}
-
-		f, err := os.Create(filename)
-		if err != nil {
-			return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
-		}
-		defer f.Close()
-
-		cw := csv.NewWriter(f)
-		defer cw.Flush()
-
-		columns := resultSet.Columns()
-		if err := cw.Write(columns); err != nil {
-			return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
-		}
-
-		row := make([]string, len(columns))
-		for _, item := range resultSet.Items() {
-			for i, col := range columns {
-				row[i], _ = item.AttributeValueAsString(col)
-			}
-			if err := cw.Write(row); err != nil {
-				return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
-			}
-		}
-
-		return nil
+func (c *TableReadController) ExportCSV(filename string) tea.Msg {
+	resultSet := c.state.ResultSet()
+	if resultSet == nil {
+		return events.Error(errors.New("no result set"))
 	}
+
+	f, err := os.Create(filename)
+	if err != nil {
+		return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
+	}
+	defer f.Close()
+
+	cw := csv.NewWriter(f)
+	defer cw.Flush()
+
+	columns := resultSet.Columns()
+	if err := cw.Write(columns); err != nil {
+		return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
+	}
+
+	row := make([]string, len(columns))
+	for _, item := range resultSet.Items() {
+		for i, col := range columns {
+			row[i], _ = item.AttributeValueAsString(col)
+		}
+		if err := cw.Write(row); err != nil {
+			return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
+		}
+	}
+
+	return nil
 }
 
 func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool) tea.Msg {
@@ -211,66 +199,58 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
 	return c.state.buildNewResultSetMessage("")
 }
 
-func (c *TableReadController) Unmark() tea.Cmd {
-	return func() tea.Msg {
-		c.state.withResultSet(func(resultSet *models.ResultSet) {
-			for i := range resultSet.Items() {
-				resultSet.SetMark(i, false)
-			}
-		})
-		return ResultSetUpdated{}
-	}
-}
-
-func (c *TableReadController) Filter() tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: "filter: ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					resultSet := c.state.ResultSet()
-					newResultSet := c.tableService.Filter(resultSet, value)
-
-					return c.setResultSetAndFilter(newResultSet, value, true)
-				}
-			},
+func (c *TableReadController) Unmark() tea.Msg {
+	c.state.withResultSet(func(resultSet *models.ResultSet) {
+		for i := range resultSet.Items() {
+			resultSet.SetMark(i, false)
 		}
+	})
+	return ResultSetUpdated{}
+}
+
+func (c *TableReadController) Filter() tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: "filter: ",
+		OnDone: func(value string) tea.Msg {
+			resultSet := c.state.ResultSet()
+			newResultSet := c.tableService.Filter(resultSet, value)
+
+			return c.setResultSetAndFilter(newResultSet, value, true)
+		},
 	}
 }
 
-func (c *TableReadController) ViewBack() tea.Cmd {
-	return func() tea.Msg {
-		viewSnapshot, err := c.workspaceService.PopSnapshot()
+func (c *TableReadController) ViewBack() tea.Msg {
+	viewSnapshot, err := c.workspaceService.PopSnapshot()
+	if err != nil {
+		return events.Error(err)
+	} else if viewSnapshot == nil {
+		return events.StatusMsg("Backstack is empty")
+	}
+
+	currentResultSet := c.state.ResultSet()
+
+	var currentQueryExpr string
+	if currentResultSet.Query != nil {
+		currentQueryExpr = currentResultSet.Query.String()
+	}
+
+	if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr {
+		log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter)
+
+		newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter)
+		return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false)
+	}
+
+	tableInfo := currentResultSet.TableInfo
+	if viewSnapshot.TableName != currentResultSet.TableInfo.Name {
+		tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName)
 		if err != nil {
 			return events.Error(err)
-		} else if viewSnapshot == nil {
-			return events.StatusMsg("Backstack is empty")
 		}
-
-		currentResultSet := c.state.ResultSet()
-
-		var currentQueryExpr string
-		if currentResultSet.Query != nil {
-			currentQueryExpr = currentResultSet.Query.String()
-		}
-
-		if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr {
-			log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter)
-
-			newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter)
-			return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false)
-		}
-
-		tableInfo := currentResultSet.TableInfo
-		if viewSnapshot.TableName != currentResultSet.TableInfo.Name {
-			tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName)
-			if err != nil {
-				return events.Error(err)
-			}
-		}
-
-		log.Printf("backstack: running query: table = '%v', query = '%v', filter = '%v'",
-			tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter)
-		return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false)
 	}
+
+	log.Printf("backstack: running query: table = '%v', query = '%v', filter = '%v'",
+		tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter)
+	return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false)
 }
diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go
index e2f3329..9784040 100644
--- a/internal/dynamo-browse/controllers/tableread_test.go
+++ b/internal/dynamo-browse/controllers/tableread_test.go
@@ -29,8 +29,7 @@ func TestTableReadController_InitTable(t *testing.T) {
 	t.Run("should prompt for table if no table name provided", func(t *testing.T) {
 		readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "")
 
-		cmd := readController.Init()
-		event := cmd()
+		event := readController.Init()
 
 		assert.IsType(t, controllers.PromptForTableMsg{}, event)
 	})
@@ -38,8 +37,7 @@ func TestTableReadController_InitTable(t *testing.T) {
 	t.Run("should scan table if table name provided", func(t *testing.T) {
 		readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "")
 
-		cmd := readController.Init()
-		event := cmd()
+		event := readController.Init()
 
 		assert.IsType(t, controllers.PromptForTableMsg{}, event)
 	})
@@ -56,13 +54,11 @@ func TestTableReadController_ListTables(t *testing.T) {
 	readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "")
 
 	t.Run("returns a list of tables", func(t *testing.T) {
-		cmd := readController.ListTables()
-		event := cmd().(controllers.PromptForTableMsg)
+		event := readController.ListTables().(controllers.PromptForTableMsg)
 
 		assert.Equal(t, []string{"alpha-table", "bravo-table"}, event.Tables)
 
-		selectedCmd := event.OnSelected("alpha-table")
-		selectedEvent := selectedCmd()
+		selectedEvent := event.OnSelected("alpha-table")
 
 		resultSet := selectedEvent.(controllers.NewResultSet)
 		assert.Equal(t, "alpha-table", resultSet.ResultSet.TableInfo.Name)
@@ -208,9 +204,7 @@ func testWorkspace(t *testing.T) *workspaces.Workspace {
 	return ws
 }
 
-func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg {
-	msg := cmd()
-
+func invokeCommand(t *testing.T, msg tea.Msg) tea.Msg {
 	err, isErr := msg.(events.ErrorMsg)
 	if isErr {
 		assert.Fail(t, fmt.Sprintf("expected no error but got one: %v", err))
@@ -218,9 +212,7 @@ func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg {
 	return msg
 }
 
-func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) {
-	msg := cmd()
-
+func invokeCommandWithPrompt(t *testing.T, msg tea.Msg, promptValue string) {
 	pi, isPi := msg.(events.PromptForInputMsg)
 	if !isPi {
 		assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one"))
@@ -229,22 +221,18 @@ func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) {
 	invokeCommand(t, pi.OnDone(promptValue))
 }
 
-func invokeCommandWithPrompts(t *testing.T, cmd tea.Cmd, promptValues ...string) {
-	msg := cmd()
-
+func invokeCommandWithPrompts(t *testing.T, msg tea.Msg, promptValues ...string) {
 	for _, promptValue := range promptValues {
 		pi, isPi := msg.(events.PromptForInputMsg)
 		if !isPi {
-			assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one"))
+			assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one: %T", msg))
 		}
 
 		msg = invokeCommand(t, pi.OnDone(promptValue))
 	}
 }
 
-func invokeCommandWithPromptsExpectingError(t *testing.T, cmd tea.Cmd, promptValues ...string) {
-	msg := cmd()
-
+func invokeCommandWithPromptsExpectingError(t *testing.T, msg tea.Msg, promptValues ...string) {
 	for _, promptValue := range promptValues {
 		pi, isPi := msg.(events.PromptForInputMsg)
 		if !isPi {
@@ -258,9 +246,7 @@ func invokeCommandWithPromptsExpectingError(t *testing.T, cmd tea.Cmd, promptVal
 	assert.True(t, isErr)
 }
 
-func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) {
-	msg := cmd()
-
+func invokeCommandExpectingError(t *testing.T, msg tea.Msg) {
 	_, isErr := msg.(events.ErrorMsg)
 	assert.True(t, isErr)
 }
diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go
index 5f79d7d..c376d3f 100644
--- a/internal/dynamo-browse/controllers/tablewrite.go
+++ b/internal/dynamo-browse/controllers/tablewrite.go
@@ -27,50 +27,46 @@ func NewTableWriteController(state *State, tableService *tables.Service, tableRe
 	}
 }
 
-func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd {
-	return func() tea.Msg {
-		twc.state.withResultSet(func(resultSet *models.ResultSet) {
-			resultSet.SetMark(idx, !resultSet.Marked(idx))
-		})
+func (twc *TableWriteController) ToggleMark(idx int) tea.Msg {
+	twc.state.withResultSet(func(resultSet *models.ResultSet) {
+		resultSet.SetMark(idx, !resultSet.Marked(idx))
+	})
 
-		return ResultSetUpdated{}
-	}
+	return ResultSetUpdated{}
 }
 
-func (twc *TableWriteController) NewItem() tea.Cmd {
-	return func() tea.Msg {
-		// Work out which keys we need to prompt for
-		rs := twc.state.ResultSet()
+func (twc *TableWriteController) NewItem() tea.Msg {
+	// Work out which keys we need to prompt for
+	rs := twc.state.ResultSet()
 
-		keyPrompts := &promptSequence{
-			prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "},
-		}
-		if rs.TableInfo.Keys.SortKey != "" {
-			keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ")
-		}
-		keyPrompts.onAllDone = func(values []string) tea.Msg {
-			twc.state.withResultSet(func(set *models.ResultSet) {
-				newItem := models.Item{}
+	keyPrompts := &promptSequence{
+		prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "},
+	}
+	if rs.TableInfo.Keys.SortKey != "" {
+		keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ")
+	}
+	keyPrompts.onAllDone = func(values []string) tea.Msg {
+		twc.state.withResultSet(func(set *models.ResultSet) {
+			newItem := models.Item{}
 
-				// TODO: deal with keys of different type
-				newItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]}
-				if len(values) == 2 {
-					newItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]}
-				}
+			// TODO: deal with keys of different type
+			newItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]}
+			if len(values) == 2 {
+				newItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]}
+			}
 
-				set.AddNewItem(newItem, models.ItemAttribute{
-					New:   true,
-					Dirty: true,
-				})
+			set.AddNewItem(newItem, models.ItemAttribute{
+				New:   true,
+				Dirty: true,
 			})
-			return twc.state.buildNewResultSetMessage("New item added")
-		}
-
-		return keyPrompts.next()
+		})
+		return twc.state.buildNewResultSetMessage("New item added")
 	}
+
+	return keyPrompts.next()
 }
 
-func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.ItemType, key string) tea.Cmd {
+func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.ItemType, key string) tea.Msg {
 	apPath := newAttrPath(key)
 
 	var attrValue types.AttributeValue
@@ -78,7 +74,7 @@ func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.Item
 		attrValue, err = apPath.follow(set.Items()[idx])
 		return err
 	}); err != nil {
-		return events.SetError(err)
+		return events.Error(err)
 	}
 
 	switch itemType {
@@ -91,7 +87,7 @@ func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.Item
 		case *types.AttributeValueMemberBOOL:
 			return twc.setBoolValue(idx, apPath)
 		default:
-			return events.SetError(errors.New("attribute type for key must be set"))
+			return events.Error(errors.New("attribute type for key must be set"))
 		}
 	case models.StringItemType:
 		return twc.setStringValue(idx, apPath)
@@ -102,35 +98,31 @@ func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.Item
 	case models.NullItemType:
 		return twc.setNullValue(idx, apPath)
 	default:
-		return events.SetError(errors.New("unsupported attribute type"))
+		return events.Error(errors.New("unsupported attribute type"))
 	}
 }
 
-func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: "string value: ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-						if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
-							if err := attr.setAt(item, &types.AttributeValueMemberS{Value: value}); err != nil {
-								return err
-							}
-							set.SetDirty(idx, true)
-							return nil
-						}); err != nil {
-							return err
-						}
-						set.RefreshColumns()
-						return nil
-					}); err != nil {
-						return events.Error(err)
+func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: "string value: ",
+		OnDone: func(value string) tea.Msg {
+			if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+				if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
+					if err := attr.setAt(item, &types.AttributeValueMemberS{Value: value}); err != nil {
+						return err
 					}
-					return ResultSetUpdated{}
+					set.SetDirty(idx, true)
+					return nil
+				}); err != nil {
+					return err
 				}
-			},
-		}
+				set.RefreshColumns()
+				return nil
+			}); err != nil {
+				return events.Error(err)
+			}
+			return ResultSetUpdated{}
+		},
 	}
 }
 
@@ -147,294 +139,262 @@ func (twc *TableWriteController) applyToItems(rs *models.ResultSet, selectedInde
 	return applyFn(selectedIndex, rs.Items()[selectedIndex])
 }
 
-func (twc *TableWriteController) setNumberValue(idx int, attr attrPath) tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: "number value: ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-						if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
-							if err := attr.setAt(item, &types.AttributeValueMemberN{Value: value}); err != nil {
-								return err
-							}
-							set.SetDirty(idx, true)
-							return nil
-						}); err != nil {
-							return err
-						}
-						set.RefreshColumns()
-						return nil
-					}); err != nil {
-						return events.Error(err)
+func (twc *TableWriteController) setNumberValue(idx int, attr attrPath) tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: "number value: ",
+		OnDone: func(value string) tea.Msg {
+			if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+				if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
+					if err := attr.setAt(item, &types.AttributeValueMemberN{Value: value}); err != nil {
+						return err
 					}
-					return ResultSetUpdated{}
-				}
-			},
-		}
-	}
-}
-
-func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Cmd {
-	return func() tea.Msg {
-		return events.PromptForInputMsg{
-			Prompt: "bool value: ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					b, err := strconv.ParseBool(value)
-					if err != nil {
-						return events.Error(err)
-					}
-
-					if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-						if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
-							if err := attr.setAt(item, &types.AttributeValueMemberBOOL{Value: b}); err != nil {
-								return err
-							}
-							set.SetDirty(idx, true)
-							return nil
-						}); err != nil {
-							return err
-						}
-						set.RefreshColumns()
-						return nil
-					}); err != nil {
-						return events.Error(err)
-					}
-					return ResultSetUpdated{}
-				}
-			},
-		}
-	}
-}
-
-func (twc *TableWriteController) setNullValue(idx int, attr attrPath) tea.Cmd {
-	return func() tea.Msg {
-		if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-			if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
-				if err := attr.setAt(item, &types.AttributeValueMemberNULL{Value: true}); err != nil {
+					set.SetDirty(idx, true)
+					return nil
+				}); err != nil {
 					return err
 				}
-				set.SetDirty(idx, true)
+				set.RefreshColumns()
 				return nil
 			}); err != nil {
-				return err
+				return events.Error(err)
 			}
-			set.RefreshColumns()
-			return nil
-		}); err != nil {
-			return events.Error(err)
-		}
-		return ResultSetUpdated{}
+			return ResultSetUpdated{}
+		},
 	}
 }
 
-func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Cmd {
-	return func() tea.Msg {
-		// Verify that the expression is valid
-		apPath := newAttrPath(key)
-
-		if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-			_, err := apPath.follow(set.Items()[idx])
-			return err
-		}); err != nil {
-			return events.Error(err)
-		}
-
-		if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
-			err := apPath.deleteAt(set.Items()[idx])
+func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg {
+	return events.PromptForInputMsg{
+		Prompt: "bool value: ",
+		OnDone: func(value string) tea.Msg {
+			b, err := strconv.ParseBool(value)
 			if err != nil {
+				return events.Error(err)
+			}
+
+			if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+				if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
+					if err := attr.setAt(item, &types.AttributeValueMemberBOOL{Value: b}); err != nil {
+						return err
+					}
+					set.SetDirty(idx, true)
+					return nil
+				}); err != nil {
+					return err
+				}
+				set.RefreshColumns()
+				return nil
+			}); err != nil {
+				return events.Error(err)
+			}
+			return ResultSetUpdated{}
+		},
+	}
+}
+
+func (twc *TableWriteController) setNullValue(idx int, attr attrPath) tea.Msg {
+	if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+		if err := twc.applyToItems(set, idx, func(idx int, item models.Item) error {
+			if err := attr.setAt(item, &types.AttributeValueMemberNULL{Value: true}); err != nil {
 				return err
 			}
-
 			set.SetDirty(idx, true)
-			set.RefreshColumns()
 			return nil
 		}); err != nil {
-			return events.Error(err)
+			return err
+		}
+		set.RefreshColumns()
+		return nil
+	}); err != nil {
+		return events.Error(err)
+	}
+	return ResultSetUpdated{}
+}
+
+func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
+	// Verify that the expression is valid
+	apPath := newAttrPath(key)
+
+	if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+		_, err := apPath.follow(set.Items()[idx])
+		return err
+	}); err != nil {
+		return events.Error(err)
+	}
+
+	if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
+		err := apPath.deleteAt(set.Items()[idx])
+		if err != nil {
+			return err
 		}
 
-		return ResultSetUpdated{}
+		set.SetDirty(idx, true)
+		set.RefreshColumns()
+		return nil
+	}); err != nil {
+		return events.Error(err)
+	}
+
+	return ResultSetUpdated{}
+}
+
+func (twc *TableWriteController) PutItem(idx int) tea.Msg {
+	resultSet := twc.state.ResultSet()
+	if !resultSet.IsDirty(idx) {
+		return events.Error(errors.New("item is not dirty"))
+	}
+
+	return events.PromptForInputMsg{
+		Prompt: "put item? ",
+		OnDone: func(value string) tea.Msg {
+			if value != "y" {
+				return nil
+			}
+
+			if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
+				return events.Error(err)
+			}
+			return ResultSetUpdated{}
+		},
 	}
 }
 
-func (twc *TableWriteController) PutItem(idx int) tea.Cmd {
-	return func() tea.Msg {
-		resultSet := twc.state.ResultSet()
-		if !resultSet.IsDirty(idx) {
-			return events.Error(errors.New("item is not dirty"))
-		}
+func (twc *TableWriteController) PutItems() tea.Msg {
+	var (
+		markedItemCount int
+	)
+	var itemsToPut []models.ItemIndex
 
-		return events.PromptForInputMsg{
-			Prompt: "put item? ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					if value != "y" {
-						return nil
-					}
-
-					if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
-						return events.Error(err)
-					}
-					return ResultSetUpdated{}
-				}
-			},
-		}
-	}
-}
-
-func (twc *TableWriteController) PutItems() tea.Cmd {
-	return func() tea.Msg {
-		var (
-			markedItemCount int
-		)
-		var itemsToPut []models.ItemIndex
-
-		twc.state.withResultSet(func(rs *models.ResultSet) {
-			if markedItems := rs.MarkedItems(); len(markedItems) > 0 {
-				for _, mi := range markedItems {
-					markedItemCount += 1
-					if rs.IsDirty(mi.Index) {
-						itemsToPut = append(itemsToPut, mi)
-					}
-				}
-			} else {
-				for i, itm := range rs.Items() {
-					if rs.IsDirty(i) {
-						itemsToPut = append(itemsToPut, models.ItemIndex{Item: itm, Index: i})
-					}
+	twc.state.withResultSet(func(rs *models.ResultSet) {
+		if markedItems := rs.MarkedItems(); len(markedItems) > 0 {
+			for _, mi := range markedItems {
+				markedItemCount += 1
+				if rs.IsDirty(mi.Index) {
+					itemsToPut = append(itemsToPut, mi)
 				}
 			}
-		})
-
-		if len(itemsToPut) == 0 {
-			if markedItemCount > 0 {
-				return events.StatusMsg("no marked items are modified")
-			} else {
-				return events.StatusMsg("no items are modified")
-			}
-		}
-
-		var promptMessage string
-		if markedItemCount > 0 {
-			promptMessage = applyToN("put ", len(itemsToPut), "marked item", "marked items", "? ")
 		} else {
-			promptMessage = applyToN("put ", len(itemsToPut), "item", "items", "? ")
-		}
-
-		return events.PromptForInputMsg{
-			Prompt: promptMessage,
-			OnDone: func(value string) tea.Cmd {
-				if value != "y" {
-					return events.SetStatus("operation aborted")
+			for i, itm := range rs.Items() {
+				if rs.IsDirty(i) {
+					itemsToPut = append(itemsToPut, models.ItemIndex{Item: itm, Index: i})
 				}
-
-				return func() tea.Msg {
-					if err := twc.state.withResultSetReturningError(func(rs *models.ResultSet) error {
-						err := twc.tableService.PutSelectedItems(context.Background(), rs, itemsToPut)
-						if err != nil {
-							return err
-						}
-						return nil
-					}); err != nil {
-						return events.Error(err)
-					}
-
-					return ResultSetUpdated{
-						statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
-					}
-				}
-			},
+			}
 		}
+	})
+
+	if len(itemsToPut) == 0 {
+		if markedItemCount > 0 {
+			return events.StatusMsg("no marked items are modified")
+		} else {
+			return events.StatusMsg("no items are modified")
+		}
+	}
+
+	var promptMessage string
+	if markedItemCount > 0 {
+		promptMessage = applyToN("put ", len(itemsToPut), "marked item", "marked items", "? ")
+	} else {
+		promptMessage = applyToN("put ", len(itemsToPut), "item", "items", "? ")
+	}
+
+	return events.PromptForInputMsg{
+		Prompt: promptMessage,
+		OnDone: func(value string) tea.Msg {
+			if value != "y" {
+				return events.SetStatus("operation aborted")
+			}
+
+			if err := twc.state.withResultSetReturningError(func(rs *models.ResultSet) error {
+				err := twc.tableService.PutSelectedItems(context.Background(), rs, itemsToPut)
+				if err != nil {
+					return err
+				}
+				return nil
+			}); err != nil {
+				return events.Error(err)
+			}
+
+			return ResultSetUpdated{
+				statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
+			}
+		},
 	}
 }
 
-func (twc *TableWriteController) TouchItem(idx int) tea.Cmd {
-	return func() tea.Msg {
-		resultSet := twc.state.ResultSet()
-		if resultSet.IsDirty(idx) {
-			return events.Error(errors.New("cannot touch dirty items"))
-		}
+func (twc *TableWriteController) TouchItem(idx int) tea.Msg {
+	resultSet := twc.state.ResultSet()
+	if resultSet.IsDirty(idx) {
+		return events.Error(errors.New("cannot touch dirty items"))
+	}
 
-		return events.PromptForInputMsg{
-			Prompt: "touch item? ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					if value != "y" {
-						return nil
-					}
+	return events.PromptForInputMsg{
+		Prompt: "touch item? ",
+		OnDone: func(value string) tea.Msg {
+			if value != "y" {
+				return nil
+			}
 
-					if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
-						return events.Error(err)
-					}
-					return ResultSetUpdated{}
-				}
-			},
-		}
+			if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
+				return events.Error(err)
+			}
+			return ResultSetUpdated{}
+		},
 	}
 }
 
-func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd {
-	return func() tea.Msg {
-		resultSet := twc.state.ResultSet()
-		if resultSet.IsDirty(idx) {
-			return events.Error(errors.New("cannot noisy touch dirty items"))
-		}
+func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Msg {
+	resultSet := twc.state.ResultSet()
+	if resultSet.IsDirty(idx) {
+		return events.Error(errors.New("cannot noisy touch dirty items"))
+	}
 
-		return events.PromptForInputMsg{
-			Prompt: "noisy touch item? ",
-			OnDone: func(value string) tea.Cmd {
-				return func() tea.Msg {
-					ctx := context.Background()
+	return events.PromptForInputMsg{
+		Prompt: "noisy touch item? ",
+		OnDone: func(value string) tea.Msg {
+			ctx := context.Background()
 
-					if value != "y" {
-						return nil
-					}
+			if value != "y" {
+				return nil
+			}
 
-					item := resultSet.Items()[0]
-					if err := twc.tableService.Delete(ctx, resultSet.TableInfo, []models.Item{item}); err != nil {
-						return events.Error(err)
-					}
+			item := resultSet.Items()[0]
+			if err := twc.tableService.Delete(ctx, resultSet.TableInfo, []models.Item{item}); err != nil {
+				return events.Error(err)
+			}
 
-					if err := twc.tableService.Put(ctx, resultSet.TableInfo, item); err != nil {
-						return events.Error(err)
-					}
+			if err := twc.tableService.Put(ctx, resultSet.TableInfo, item); err != nil {
+				return events.Error(err)
+			}
 
-					return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
-				}
-			},
-		}
+			return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
+		},
 	}
 }
 
-func (twc *TableWriteController) DeleteMarked() tea.Cmd {
-	return func() tea.Msg {
-		resultSet := twc.state.ResultSet()
-		markedItems := resultSet.MarkedItems()
+func (twc *TableWriteController) DeleteMarked() tea.Msg {
+	resultSet := twc.state.ResultSet()
+	markedItems := resultSet.MarkedItems()
 
-		if len(markedItems) == 0 {
-			return events.StatusMsg("no marked items")
-		}
+	if len(markedItems) == 0 {
+		return events.StatusMsg("no marked items")
+	}
 
-		return events.PromptForInputMsg{
-			Prompt: applyToN("delete ", len(markedItems), "item", "items", "? "),
-			OnDone: func(value string) tea.Cmd {
-				if value != "y" {
-					return events.SetStatus("operation aborted")
-				}
+	return events.PromptForInputMsg{
+		Prompt: applyToN("delete ", len(markedItems), "item", "items", "? "),
+		OnDone: func(value string) tea.Msg {
+			if value != "y" {
+				return events.SetStatus("operation aborted")
+			}
 
-				return func() tea.Msg {
-					ctx := context.Background()
-					if err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item {
-						return index.Item
-					})); err != nil {
-						return events.Error(err)
-					}
+			ctx := context.Background()
+			if err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item {
+				return index.Item
+			})); err != nil {
+				return events.Error(err)
+			}
 
-					return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
-				}
-			},
-		}
+			return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
+		},
 	}
 }
 
diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go
index eaefd2f..3cf2918 100644
--- a/internal/dynamo-browse/ui/model.go
+++ b/internal/dynamo-browse/ui/model.go
@@ -46,27 +46,27 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
 	cc.AddCommands(&commandctrl.CommandContext{
 		Commands: map[string]commandctrl.Command{
 			"quit": commandctrl.NoArgCommand(tea.Quit),
-			"table": func(args []string) tea.Cmd {
+			"table": func(args []string) tea.Msg {
 				if len(args) == 0 {
 					return rc.ListTables()
 				} else {
 					return rc.ScanTable(args[0])
 				}
 			},
-			"export": func(args []string) tea.Cmd {
+			"export": func(args []string) tea.Msg {
 				if len(args) == 0 {
-					return events.SetError(errors.New("expected filename"))
+					return events.Error(errors.New("expected filename"))
 				}
 				return rc.ExportCSV(args[0])
 			},
-			"unmark": commandctrl.NoArgCommand(rc.Unmark()),
-			"delete": commandctrl.NoArgCommand(wc.DeleteMarked()),
+			"unmark": commandctrl.NoArgCommand(rc.Unmark),
+			"delete": commandctrl.NoArgCommand(wc.DeleteMarked),
 
 			// TEMP
-			"new-item": commandctrl.NoArgCommand(wc.NewItem()),
-			"set-attr": func(args []string) tea.Cmd {
+			"new-item": commandctrl.NoArgCommand(wc.NewItem),
+			"set-attr": func(args []string) tea.Msg {
 				if len(args) == 0 {
-					return events.SetError(errors.New("expected field"))
+					return events.Error(errors.New("expected field"))
 				}
 
 				var itemType = models.UnsetItemType
@@ -81,27 +81,27 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
 					case "-NULL":
 						itemType = models.NullItemType
 					default:
-						return events.SetError(errors.New("unrecognised item type"))
+						return events.Error(errors.New("unrecognised item type"))
 					}
 					args = args[1:]
 				}
 
 				return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, args[0])
 			},
-			"del-attr": func(args []string) tea.Cmd {
+			"del-attr": func(args []string) tea.Msg {
 				if len(args) == 0 {
-					return events.SetError(errors.New("expected field"))
+					return events.Error(errors.New("expected field"))
 				}
 				return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0])
 			},
 
-			"put": func(args []string) tea.Cmd {
+			"put": func(args []string) tea.Msg {
 				return wc.PutItems()
 			},
-			"touch": func(args []string) tea.Cmd {
+			"touch": func(args []string) tea.Msg {
 				return wc.TouchItem(dtv.SelectedItemIndex())
 			},
-			"noisy-touch": func(args []string) tea.Cmd {
+			"noisy-touch": func(args []string) tea.Msg {
 				return wc.NoisyTouchItem(dtv.SelectedItemIndex())
 			},
 
@@ -129,7 +129,7 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
 }
 
 func (m Model) Init() tea.Cmd {
-	return m.tableReadController.Init()
+	return m.tableReadController.Init
 }
 
 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -141,21 +141,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			switch msg.String() {
 			case "m":
 				if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
-					return m, m.tableWriteController.ToggleMark(idx)
+					return m, func() tea.Msg { return m.tableWriteController.ToggleMark(idx) }
 				}
 			case "R":
-				return m, m.tableReadController.Rescan()
+				return m, m.tableReadController.Rescan
 			case "?":
-				return m, m.tableReadController.PromptForQuery()
+				return m, m.tableReadController.PromptForQuery
 			case "/":
-				return m, m.tableReadController.Filter()
+				return m, m.tableReadController.Filter
 			case "backspace":
-				return m, m.tableReadController.ViewBack()
+				return m, m.tableReadController.ViewBack
 			//case "e":
 			//	m.itemEdit.Visible()
 			//	return m, nil
 			case ":":
-				return m, m.commandController.Prompt()
+				return m, m.commandController.Prompt
 			case "ctrl+c", "esc":
 				return m, tea.Quit
 			}
diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go
index 995f0e5..a48dd4f 100644
--- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go
+++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go
@@ -67,7 +67,7 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				pendingInput := s.pendingInput
 				s.pendingInput = nil
 
-				return s, pendingInput.OnDone(s.textInput.Value())
+				return s, func() tea.Msg { return pendingInput.OnDone(s.textInput.Value()) }
 			default:
 				if msg.Type == tea.KeyRunes {
 					msg.Runes = sliceutils.Filter(msg.Runes, func(r rune) bool { return r != '\x0d' && r != '\x0a' })
diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/model.go b/internal/dynamo-browse/ui/teamodels/tableselect/model.go
index b7332ef..18c679b 100644
--- a/internal/dynamo-browse/ui/teamodels/tableselect/model.go
+++ b/internal/dynamo-browse/ui/teamodels/tableselect/model.go
@@ -55,7 +55,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					var sel controllers.PromptForTableMsg
 					sel, m.pendingSelection = *m.pendingSelection, nil
 
-					return m, sel.OnSelected(m.listController.list.SelectedItem().(tableItem).name)
+					return m, func() tea.Msg { return sel.OnSelected(m.listController.list.SelectedItem().(tableItem).name) }
 				}
 			}
 
diff --git a/internal/slog-view/ui/model.go b/internal/slog-view/ui/model.go
index a67de34..e153bbd 100644
--- a/internal/slog-view/ui/model.go
+++ b/internal/slog-view/ui/model.go
@@ -62,7 +62,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			switch msg.String() {
 			// TEMP
 			case ":":
-				return m, m.cmdController.Prompt()
+				return m, func() tea.Msg { return m.cmdController.Prompt() }
 			case "w":
 				return m, m.controller.ViewLogLineFullScreen(m.logLines.SelectedLogLine())
 			// END TEMP
diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go
index 2078b78..c54f316 100644
--- a/internal/ssm-browse/controllers/ssmcontroller.go
+++ b/internal/ssm-browse/controllers/ssmcontroller.go
@@ -39,26 +39,24 @@ func (c *SSMController) Fetch() tea.Cmd {
 	}
 }
 
-func (c *SSMController) ChangePrefix(newPrefix string) tea.Cmd {
-	return func() tea.Msg {
-		res, err := c.service.List(context.Background(), newPrefix)
-		if err != nil {
-			return events.Error(err)
-		}
+func (c *SSMController) ChangePrefix(newPrefix string) tea.Msg {
+	res, err := c.service.List(context.Background(), newPrefix)
+	if err != nil {
+		return events.Error(err)
+	}
 
-		c.mutex.Lock()
-		defer c.mutex.Unlock()
-		c.prefix = newPrefix
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+	c.prefix = newPrefix
 
-		return NewParameterListMsg{
-			Prefix:     c.prefix,
-			Parameters: res,
-		}
+	return NewParameterListMsg{
+		Prefix:     c.prefix,
+		Parameters: res,
 	}
 }
 
-func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd {
-	return events.PromptForInput("New key: ", func(value string) tea.Cmd {
+func (c *SSMController) Clone(param models.SSMParameter) tea.Msg {
+	return events.PromptForInput("New key: ", func(value string) tea.Msg {
 		return func() tea.Msg {
 			ctx := context.Background()
 			if err := c.service.Clone(ctx, param, value); err != nil {
@@ -78,23 +76,21 @@ func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd {
 	})
 }
 
-func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Cmd {
-	return events.Confirm("delete parameter? ", func() tea.Cmd {
-		return func() tea.Msg {
-			ctx := context.Background()
-			if err := c.service.Delete(ctx, param); err != nil {
-				return events.Error(err)
-			}
+func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Msg {
+	return events.Confirm("delete parameter? ", func() tea.Msg {
+		ctx := context.Background()
+		if err := c.service.Delete(ctx, param); err != nil {
+			return events.Error(err)
+		}
 
-			res, err := c.service.List(context.Background(), c.prefix)
-			if err != nil {
-				return events.Error(err)
-			}
+		res, err := c.service.List(context.Background(), c.prefix)
+		if err != nil {
+			return events.Error(err)
+		}
 
-			return NewParameterListMsg{
-				Prefix:     c.prefix,
-				Parameters: res,
-			}
+		return NewParameterListMsg{
+			Prefix:     c.prefix,
+			Parameters: res,
 		}
 	})
 }
diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go
index a0fceae..985b694 100644
--- a/internal/ssm-browse/ui/model.go
+++ b/internal/ssm-browse/ui/model.go
@@ -32,17 +32,17 @@ func NewModel(controller *controllers.SSMController, cmdController *commandctrl.
 
 	cmdController.AddCommands(&commandctrl.CommandContext{
 		Commands: map[string]commandctrl.Command{
-			"clone": func(args []string) tea.Cmd {
+			"clone": func(args []string) tea.Msg {
 				if currentParam := ssmList.CurrentParameter(); currentParam != nil {
 					return controller.Clone(*currentParam)
 				}
-				return events.SetError(errors.New("no parameter selected"))
+				return events.Error(errors.New("no parameter selected"))
 			},
-			"delete": func(args []string) tea.Cmd {
+			"delete": func(args []string) tea.Msg {
 				if currentParam := ssmList.CurrentParameter(); currentParam != nil {
 					return controller.DeleteParameter(*currentParam)
 				}
-				return events.SetError(errors.New("no parameter selected"))
+				return events.Error(errors.New("no parameter selected"))
 			},
 		},
 	})
@@ -75,7 +75,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			switch msg.String() {
 			// TEMP
 			case ":":
-				return m, m.cmdController.Prompt()
+				return m, func() tea.Msg { return m.cmdController.Prompt() }
 			// END TEMP
 
 			case "ctrl+c", "q":