diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go
index 1c83968..4a06960 100644
--- a/cmd/dynamo-browse/main.go
+++ b/cmd/dynamo-browse/main.go
@@ -7,6 +7,8 @@ import (
 	"github.com/aws/aws-sdk-go-v2/config"
 	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/lmika/awstools/internal/common/ui/dispatcher"
+	"github.com/lmika/awstools/internal/dynamo-browse/controllers"
 	"github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo"
 	"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
 	"github.com/lmika/awstools/internal/dynamo-browse/ui"
@@ -38,7 +40,12 @@ func main() {
 	tableService := tables.NewService(dynamoProvider)
 
 	loopback := &msgLoopback{}
-	uiModel := ui.NewModel(tableService, loopback, *flagTable)
+	uiDispatcher := dispatcher.NewDispatcher(loopback)
+
+	tableReadController := controllers.NewTableReadController(tableService, *flagTable)
+	tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable)
+
+	uiModel := ui.NewModel(uiDispatcher, tableReadController, tableWriteController)
 	p := tea.NewProgram(uiModel, tea.WithAltScreen())
 	loopback.program = p
 
diff --git a/cmd/sqs-browse/main.go b/cmd/sqs-browse/main.go
index 97c0477..18daa58 100644
--- a/cmd/sqs-browse/main.go
+++ b/cmd/sqs-browse/main.go
@@ -7,9 +7,12 @@ import (
 	"github.com/aws/aws-sdk-go-v2/config"
 	"github.com/aws/aws-sdk-go-v2/service/sqs"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/lmika/awstools/internal/common/ui/dispatcher"
+	"github.com/lmika/awstools/internal/sqs-browse/controllers"
 	"github.com/lmika/awstools/internal/sqs-browse/models"
 	"github.com/lmika/awstools/internal/sqs-browse/providers/memstore"
 	sqsprovider "github.com/lmika/awstools/internal/sqs-browse/providers/sqs"
+	"github.com/lmika/awstools/internal/sqs-browse/services/messages"
 	"github.com/lmika/awstools/internal/sqs-browse/services/pollmessage"
 	"github.com/lmika/awstools/internal/sqs-browse/ui"
 	"github.com/lmika/events"
@@ -20,6 +23,7 @@ import (
 
 func main() {
 	var flagQueue = flag.String("q", "", "queue to poll")
+	var flagTarget = flag.String("t", "", "target queue to push to")
 	flag.Parse()
 
 	ctx := context.Background()
@@ -32,12 +36,19 @@ func main() {
 	bus := events.New()
 
 	msgStore := memstore.NewStore()
-	msgPoller := sqsprovider.NewProvider(sqsClient)
+	sqsProvider := sqsprovider.NewProvider(sqsClient)
 
-	pollService := pollmessage.NewService(msgStore, msgPoller, *flagQueue, bus)
+	messageService := messages.NewService(sqsProvider)
+	pollService := pollmessage.NewService(msgStore, sqsProvider, *flagQueue, bus)
 
-	uiModel := ui.NewModel()
+	msgSendingHandlers := controllers.NewMessageSendingController(messageService, *flagTarget)
+
+	loopback := &msgLoopback{}
+	uiDispatcher := dispatcher.NewDispatcher(loopback)
+
+	uiModel := ui.NewModel(uiDispatcher, msgSendingHandlers)
 	p := tea.NewProgram(uiModel, tea.WithAltScreen())
+	loopback.program = p
 
 	bus.On("new-messages", func(m []*models.Message) { p.Send(ui.NewMessagesEvent(m)) })
 
@@ -58,4 +69,12 @@ func main() {
 		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
 	}
-}
\ No newline at end of file
+}
+
+type msgLoopback struct {
+	program *tea.Program
+}
+
+func (m *msgLoopback) Send(msg tea.Msg) {
+	m.program.Send(msg)
+}
diff --git a/go.mod b/go.mod
index a734757..9db652d 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/lmika/awstools
 go 1.17
 
 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.15.0 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.13.1 // indirect
 	github.com/aws/aws-sdk-go-v2/credentials v1.8.0 // indirect
diff --git a/go.sum b/go.sum
index 3884602..55f630b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,4 @@
+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-v2 v1.13.0 h1:1XIXAfxsEmbhbj5ry3D3vX+6ZcUYvIqSm4CWWEuGZCA=
 github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w=
diff --git a/internal/common/ui/dispatcher/context.go b/internal/common/ui/dispatcher/context.go
new file mode 100644
index 0000000..181128e
--- /dev/null
+++ b/internal/common/ui/dispatcher/context.go
@@ -0,0 +1,30 @@
+package dispatcher
+
+import (
+	"fmt"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/lmika/awstools/internal/common/ui/events"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+)
+
+type dispatcherContext struct {
+	d *Dispatcher
+}
+
+func (dc dispatcherContext) Messagef(format string, args ...interface{}) {
+	dc.d.publisher.Send(events.Message(fmt.Sprintf(format, args...)))
+}
+
+func (dc dispatcherContext) Send(teaMessage tea.Msg) {
+	dc.d.publisher.Send(teaMessage)
+}
+
+func (dc dispatcherContext) Message(msg string) {
+	dc.d.publisher.Send(events.Message(msg))
+}
+
+func (dc dispatcherContext) Input(prompt string, onDone uimodels.Operation) {
+	dc.d.publisher.Send(events.PromptForInput{
+		OnDone: onDone,
+	})
+}
diff --git a/internal/common/ui/dispatcher/dispatcher.go b/internal/common/ui/dispatcher/dispatcher.go
new file mode 100644
index 0000000..e126b4d
--- /dev/null
+++ b/internal/common/ui/dispatcher/dispatcher.go
@@ -0,0 +1,49 @@
+package dispatcher
+
+import (
+	"context"
+	"github.com/lmika/awstools/internal/common/ui/events"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/pkg/errors"
+	"sync"
+)
+
+type Dispatcher struct {
+	mutex     *sync.Mutex
+	runningOp uimodels.Operation
+	publisher MessagePublisher
+}
+
+
+func NewDispatcher(publisher MessagePublisher) *Dispatcher {
+	return &Dispatcher{
+		mutex:     new(sync.Mutex),
+		publisher: publisher,
+	}
+}
+
+func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) {
+	d.mutex.Lock()
+	defer d.mutex.Unlock()
+
+	if d.runningOp != nil {
+		d.publisher.Send(events.Error(errors.New("operation already running")))
+	}
+
+	d.runningOp = operation
+	go func() {
+		subCtx := uimodels.WithContext(ctx, dispatcherContext{d})
+
+		err := operation.Execute(subCtx)
+		if err != nil {
+			d.publisher.Send(events.Error(err))
+		}
+
+		d.mutex.Lock()
+		defer d.mutex.Unlock()
+		d.runningOp = nil
+	}()
+}
+
+
+
diff --git a/internal/common/ui/dispatcher/iface.go b/internal/common/ui/dispatcher/iface.go
new file mode 100644
index 0000000..064624d
--- /dev/null
+++ b/internal/common/ui/dispatcher/iface.go
@@ -0,0 +1,7 @@
+package dispatcher
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type MessagePublisher interface {
+	Send(msg tea.Msg)
+}
\ No newline at end of file
diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go
new file mode 100644
index 0000000..2f96a3f
--- /dev/null
+++ b/internal/common/ui/events/errors.go
@@ -0,0 +1,16 @@
+package events
+
+import (
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+)
+
+// Error indicates that an error occurred
+type Error error
+
+// Message indicates that a message should be shown to the user
+type Message string
+
+// PromptForInput indicates that the context is requesting a line of input
+type PromptForInput struct {
+	OnDone uimodels.Operation
+}
\ No newline at end of file
diff --git a/internal/common/ui/uimodels/context.go b/internal/common/ui/uimodels/context.go
new file mode 100644
index 0000000..dae4ab4
--- /dev/null
+++ b/internal/common/ui/uimodels/context.go
@@ -0,0 +1,15 @@
+package uimodels
+
+import "context"
+
+type uiContextKeyType struct {}
+var uiContextKey = uiContextKeyType{}
+
+func Ctx(ctx context.Context) UIContext {
+	uiCtx, _ := ctx.Value(uiContextKey).(UIContext)
+	return uiCtx
+}
+
+func WithContext(ctx context.Context, uiContext UIContext) context.Context {
+	return context.WithValue(ctx, uiContextKey, uiContext)
+}
diff --git a/internal/common/ui/uimodels/iface.go b/internal/common/ui/uimodels/iface.go
new file mode 100644
index 0000000..002e83f
--- /dev/null
+++ b/internal/common/ui/uimodels/iface.go
@@ -0,0 +1,10 @@
+package uimodels
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type UIContext interface {
+	Send(teaMessage tea.Msg)
+	Message(msg string)
+	Messagef(format string, args ...interface{})
+	Input(prompt string, onDone Operation)
+}
diff --git a/internal/common/ui/uimodels/operations.go b/internal/common/ui/uimodels/operations.go
new file mode 100644
index 0000000..c1f504a
--- /dev/null
+++ b/internal/common/ui/uimodels/operations.go
@@ -0,0 +1,14 @@
+package uimodels
+
+import "context"
+
+type Operation interface {
+	Execute(ctx context.Context) error
+}
+
+type OperationFn func(ctx context.Context) error
+
+func (f OperationFn) Execute(ctx context.Context) error {
+	return f(ctx)
+}
+
diff --git a/internal/common/ui/uimodels/promptvalue.go b/internal/common/ui/uimodels/promptvalue.go
new file mode 100644
index 0000000..d829687
--- /dev/null
+++ b/internal/common/ui/uimodels/promptvalue.go
@@ -0,0 +1,15 @@
+package uimodels
+
+import "context"
+
+type promptValueKeyType struct {}
+var promptValueKey = promptValueKeyType{}
+
+func PromptValue(ctx context.Context) string {
+	value, _ := ctx.Value(promptValueKey).(string)
+	return value
+}
+
+func WithPromptValue(ctx context.Context, value string) context.Context {
+	return context.WithValue(ctx, promptValueKey, value)
+}
diff --git a/internal/dynamo-browse/ui/events.go b/internal/dynamo-browse/controllers/events.go
similarity index 52%
rename from internal/dynamo-browse/ui/events.go
rename to internal/dynamo-browse/controllers/events.go
index 9165527..6f51b9a 100644
--- a/internal/dynamo-browse/ui/events.go
+++ b/internal/dynamo-browse/controllers/events.go
@@ -1,10 +1,7 @@
-package ui
+package controllers
 
 import "github.com/lmika/awstools/internal/dynamo-browse/models"
 
-type newResultSet struct {
+type NewResultSet struct {
 	ResultSet *models.ResultSet
 }
-
-type setStatusMessage string
-type errorRaised error
\ No newline at end of file
diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go
new file mode 100644
index 0000000..71a6dae
--- /dev/null
+++ b/internal/dynamo-browse/controllers/tableread.go
@@ -0,0 +1,44 @@
+package controllers
+
+import (
+	"context"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
+)
+
+type TableReadController struct {
+	tableService *tables.Service
+	tableName string
+}
+
+func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController {
+	return &TableReadController{
+		tableService: tableService,
+		tableName: tableName,
+	}
+}
+
+func (c *TableReadController) Scan() uimodels.Operation {
+	return uimodels.OperationFn(func(ctx context.Context) error {
+		return c.doScan(ctx, false)
+	})
+}
+
+func (c *TableReadController) doScan(ctx context.Context, quiet bool) error {
+	uiCtx := uimodels.Ctx(ctx)
+
+	if !quiet {
+		uiCtx.Message("Scanning...")
+	}
+
+	resultSet, err := c.tableService.Scan(ctx, c.tableName)
+	if err != nil {
+		return err
+	}
+
+	if !quiet {
+		uiCtx.Messagef("Found %d items", len(resultSet.Items))
+	}
+	uiCtx.Send(NewResultSet{resultSet})
+	return nil
+}
\ No newline at end of file
diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go
new file mode 100644
index 0000000..1482448
--- /dev/null
+++ b/internal/dynamo-browse/controllers/tablewrite.go
@@ -0,0 +1,53 @@
+package controllers
+
+import (
+	"context"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
+	"github.com/lmika/awstools/internal/dynamo-browse/models"
+	"github.com/pkg/errors"
+)
+
+type TableWriteController struct {
+	tableService *tables.Service
+	tableReadControllers *TableReadController
+	tableName string
+}
+
+func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController, tableName string) *TableWriteController {
+	return &TableWriteController{
+		tableService: tableService,
+		tableReadControllers: tableReadControllers,
+		tableName: tableName,
+	}
+}
+
+func (c *TableWriteController) Delete(item models.Item) uimodels.Operation {
+	return uimodels.OperationFn(func(ctx context.Context) error {
+		uiCtx := uimodels.Ctx(ctx)
+
+		// TODO: only do if rw mode enabled
+
+		uiCtx.Input("Delete item?", uimodels.OperationFn(func(ctx context.Context) error {
+			uiCtx := uimodels.Ctx(ctx)
+
+			if uimodels.PromptValue(ctx) != "y" {
+				return errors.New("operation aborted")
+			}
+
+			// Delete the item
+			if err := c.tableService.Delete(ctx, c.tableName, item); err != nil {
+				return err
+			}
+
+			// Rescan to get updated items
+			if err := c.tableReadControllers.doScan(ctx, true); err != nil {
+				return err
+			}
+
+			uiCtx.Message("Item deleted")
+			return nil
+		}))
+		return nil
+	})
+}
diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go
index cb07719..f1910c1 100644
--- a/internal/dynamo-browse/providers/dynamo/provider.go
+++ b/internal/dynamo-browse/providers/dynamo/provider.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
 	"github.com/lmika/awstools/internal/dynamo-browse/models"
 	"github.com/pkg/errors"
 )
@@ -31,3 +32,11 @@ func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.It
 
 	return items, nil
 }
+
+func (p *Provider) DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error {
+	_, err := p.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
+		TableName: aws.String(tableName),
+		Key: key,
+	})
+	return errors.Wrap(err, "could not delete item")
+}
\ No newline at end of file
diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go
index b8a059f..1e46d27 100644
--- a/internal/dynamo-browse/services/tables/iface.go
+++ b/internal/dynamo-browse/services/tables/iface.go
@@ -2,9 +2,11 @@ package tables
 
 import (
 	"context"
+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
 	"github.com/lmika/awstools/internal/dynamo-browse/models"
 )
 
 type TableProvider interface {
 	ScanItems(ctx context.Context, tableName string) ([]models.Item, error)
+	DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error
 }
diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go
index b3fbbd5..fde7cb0 100644
--- a/internal/dynamo-browse/services/tables/service.go
+++ b/internal/dynamo-browse/services/tables/service.go
@@ -2,6 +2,7 @@ package tables
 
 import (
 	"context"
+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
 	"github.com/lmika/awstools/internal/dynamo-browse/models"
 	"github.com/pkg/errors"
 	"sort"
@@ -50,3 +51,11 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er
 		Items: results,
 	}, nil
 }
+
+func (s *Service) Delete(ctx context.Context, name string, item models.Item) error {
+	// TODO: do not hardcode keys
+	return s.provider.DeleteItem(ctx, name, map[string]types.AttributeValue{
+		"pk": item["pk"],
+		"sk": item["sk"],
+	})
+}
diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go
index 30b80f1..9e1549b 100644
--- a/internal/dynamo-browse/ui/model.go
+++ b/internal/dynamo-browse/ui/model.go
@@ -5,42 +5,59 @@ import (
 	"fmt"
 	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
 	table "github.com/calyptia/go-bubble-table"
+	"github.com/charmbracelet/bubbles/textinput"
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/lmika/awstools/internal/common/ui/dispatcher"
+	"github.com/lmika/awstools/internal/common/ui/events"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/lmika/awstools/internal/dynamo-browse/controllers"
 	"github.com/lmika/awstools/internal/dynamo-browse/models"
-	"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
-	"log"
 	"strings"
 	"text/tabwriter"
 )
 
+var (
+	headerStyle = lipgloss.NewStyle().
+		Bold(true).
+		Foreground(lipgloss.Color("#ffffff")).
+		Background(lipgloss.Color("#4479ff"))
+)
+
 type uiModel struct {
 	table    table.Model
 	viewport viewport.Model
 
-	msgPublisher MessagePublisher
-	tableService *tables.Service
-	tableName    string
-
 	tableWidth, tableHeight int
 
 	ready     bool
 	resultSet *models.ResultSet
 	message   string
+
+	pendingInput *events.PromptForInput
+	textInput    textinput.Model
+
+	dispatcher           *dispatcher.Dispatcher
+	tableReadController  *controllers.TableReadController
+	tableWriteController *controllers.TableWriteController
 }
 
-func NewModel(tableService *tables.Service, msgPublisher MessagePublisher, tableName string) tea.Model {
+func NewModel(dispatcher *dispatcher.Dispatcher, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model {
 	tbl := table.New([]string{"pk", "sk"}, 100, 20)
 	rows := make([]table.Row, 0)
 	tbl.SetRows(rows)
 
+	textInput := textinput.New()
+
 	model := uiModel{
-		table:        tbl,
-		tableService: tableService,
-		tableName:    tableName,
-		msgPublisher: msgPublisher,
-		message:      "Press s to scan",
+		table:     tbl,
+		message:   "Press s to scan",
+		textInput: textInput,
+
+		dispatcher:           dispatcher,
+		tableReadController:  tableReadController,
+		tableWriteController: tableWriteController,
 	}
 
 	return model
@@ -65,16 +82,19 @@ func (m *uiModel) updateTable() {
 	m.table = newTbl
 }
 
+func (m *uiModel) selectedItem() (itemTableRow, bool) {
+	if m.ready && m.resultSet != nil && len(m.resultSet.Items) > 0 {
+		selectedItem, ok := m.table.SelectedRow().(itemTableRow)
+		if ok {
+			return selectedItem, true
+		}
+	}
+
+	return itemTableRow{}, false
+}
+
 func (m *uiModel) updateViewportToSelectedMessage() {
-	if !m.ready {
-		return
-	}
-
-	if m.resultSet == nil || len(m.resultSet.Items) == 0 {
-		return
-	}
-
-	selectedItem, ok := m.table.SelectedRow().(itemTableRow)
+	selectedItem, ok := m.selectedItem()
 	if !ok {
 		m.viewport.SetContent("(no row selected)")
 		return
@@ -83,17 +103,15 @@ func (m *uiModel) updateViewportToSelectedMessage() {
 	viewportContent := &strings.Builder{}
 	tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
 	for _, colName := range selectedItem.resultSet.Columns {
-		fmt.Fprintf(tabWriter, "%v\t", colName)
-
 		switch colVal := selectedItem.item[colName].(type) {
 		case nil:
-			fmt.Fprintln(tabWriter, "(nil)")
+			break
 		case *types.AttributeValueMemberS:
-			fmt.Fprintln(tabWriter, colVal.Value)
+			fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value)
 		case *types.AttributeValueMemberN:
-			fmt.Fprintln(tabWriter, colVal.Value)
+			fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value)
 		default:
-			fmt.Fprintln(tabWriter, "(other)")
+			fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)")
 		}
 	}
 
@@ -102,26 +120,38 @@ func (m *uiModel) updateViewportToSelectedMessage() {
 }
 
 func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var textInputCommands tea.Cmd
+
 	switch msg := msg.(type) {
-	case setStatusMessage:
-		m.message = ""
-	case errorRaised:
-		m.message = "Error: " + msg.Error()
-	case newResultSet:
+
+	// Local events
+	case controllers.NewResultSet:
 		m.resultSet = msg.ResultSet
 		m.updateTable()
 		m.updateViewportToSelectedMessage()
+
+	// Shared events
+	case events.Error:
+		m.message = "Error: " + msg.Error()
+	case events.Message:
+		m.message = string(msg)
+	case events.PromptForInput:
+		m.textInput.Focus()
+		m.textInput.SetValue("")
+		m.pendingInput = &msg
+
+	// Tea events
 	case tea.WindowSizeMsg:
-		footerHeight := lipgloss.Height(m.footerView())
+		fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView())
 		tableHeight := msg.Height / 2
 
 		if !m.ready {
-			m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-footerHeight)
+			m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-fixedViewsHeight)
 			m.viewport.SetContent("(no message selected)")
 			m.ready = true
 		} else {
 			m.viewport.Width = msg.Width
-			m.viewport.Height = msg.Height - tableHeight - footerHeight
+			m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight
 		}
 
 		m.tableWidth, m.tableHeight = msg.Width, tableHeight
@@ -129,15 +159,21 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tea.KeyMsg:
 
+		// If text input in focus, allow that to accept input messages
+		if m.pendingInput != nil {
+			switch msg.String() {
+			case "ctrl+c", "esc":
+				m.pendingInput = nil
+			case "enter":
+				m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
+				m.pendingInput = nil
+			default:
+				m.textInput, textInputCommands = m.textInput.Update(msg)
+			}
+			break
+		}
+
 		switch msg.String() {
-		case "s":
-			m.startOperation("Scanning...", func(ctx context.Context) (tea.Msg, error) {
-				resultSet, err := m.tableService.Scan(ctx, m.tableName)
-				if err != nil {
-					return nil, err
-				}
-				return newResultSet{resultSet}, nil
-			})
 		case "ctrl+c", "q":
 			return m, tea.Quit
 		case "up", "i":
@@ -146,7 +182,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "down", "k":
 			m.table.GoDown()
 			m.updateViewportToSelectedMessage()
+
+		// TODO: these should be moved somewhere else
+		case "s":
+			m.dispatcher.Start(context.Background(), m.tableReadController.Scan())
+		case "D":
+			if selectedItem, ok := m.selectedItem(); ok {
+				m.dispatcher.Start(context.Background(), m.tableWriteController.Delete(selectedItem.item))
+			}
 		}
+	default:
+		m.textInput, textInputCommands = m.textInput.Update(msg)
 	}
 
 	updatedTable, tableMsgs := m.table.Update(msg)
@@ -155,21 +201,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	m.table = updatedTable
 	m.viewport = updatedViewport
 
-	return m, tea.Batch(tableMsgs, viewportMsgs)
-}
-
-// TODO: this should probably be a separate service
-func (m *uiModel) startOperation(msg string, op func(ctx context.Context) (tea.Msg, error)) {
-	m.message = msg
-	go func() {
-		resMsg, err := op(context.Background())
-		if err != nil {
-			m.msgPublisher.Send(errorRaised(err))
-		} else if resMsg != nil {
-			m.msgPublisher.Send(resMsg)
-		}
-		m.msgPublisher.Send(setStatusMessage(""))
-	}()
+	return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs)
 }
 
 func (m uiModel) View() string {
@@ -177,9 +209,35 @@ func (m uiModel) View() string {
 		return "Initializing"
 	}
 
-	log.Println("Returning full view")
-	return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.viewport.View(), m.footerView())
-	//return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.footerView())
+	if m.pendingInput != nil {
+		return lipgloss.JoinVertical(lipgloss.Top,
+			m.headerView(),
+			m.table.View(),
+			m.splitterView(),
+			m.viewport.View(),
+			m.textInput.View(),
+		)
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Top,
+		m.headerView(),
+		m.table.View(),
+		m.splitterView(),
+		m.viewport.View(),
+		m.footerView(),
+	)
+}
+
+func (m uiModel) headerView() string {
+	title := headerStyle.Render("Table: XXX")
+	line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
+	return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
+}
+
+func (m uiModel) splitterView() string {
+	title := headerStyle.Render("Item")
+	line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
+	return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
 }
 
 func (m uiModel) footerView() string {
diff --git a/internal/sqs-browse/controllers/forward.go b/internal/sqs-browse/controllers/forward.go
new file mode 100644
index 0000000..8e538e7
--- /dev/null
+++ b/internal/sqs-browse/controllers/forward.go
@@ -0,0 +1,39 @@
+package controllers
+
+import (
+	"context"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/lmika/awstools/internal/sqs-browse/models"
+	"github.com/lmika/awstools/internal/sqs-browse/services/messages"
+	"github.com/pkg/errors"
+)
+
+type MessageSendingController struct {
+	messageService *messages.Service
+	targetQueue    string
+}
+
+func NewMessageSendingController(messageService *messages.Service, targetQueue string) *MessageSendingController {
+	return &MessageSendingController{
+		messageService: messageService,
+		targetQueue:    targetQueue,
+	}
+}
+
+func (msh *MessageSendingController) ForwardMessage(message models.Message) uimodels.Operation {
+	return uimodels.OperationFn(func(ctx context.Context) error {
+		uiCtx := uimodels.Ctx(ctx)
+
+		if msh.targetQueue == "" {
+			return errors.New("target queue not set")
+		}
+
+		messageId, err := msh.messageService.SendTo(ctx, message, msh.targetQueue)
+		if err != nil {
+			return errors.Wrapf(err, "cannot send message to %v", msh.targetQueue)
+		}
+
+		uiCtx.Message("Message sent to " + msh.targetQueue + ", id = " + messageId)
+		return nil
+	})
+}
diff --git a/internal/sqs-browse/providers/sqs/provider.go b/internal/sqs-browse/providers/sqs/provider.go
index 76639c6..77c166b 100644
--- a/internal/sqs-browse/providers/sqs/provider.go
+++ b/internal/sqs-browse/providers/sqs/provider.go
@@ -19,6 +19,20 @@ func NewProvider(client *sqs.Client) *Provider {
 	return &Provider{client: client}
 }
 
+func (p *Provider) SendMessage(ctx context.Context, msg models.Message, queue string) (string, error) {
+	// TEMP :: queue URL
+
+	out, err := p.client.SendMessage(ctx, &sqs.SendMessageInput{
+		QueueUrl: aws.String(queue),
+		MessageBody: aws.String(msg.Data),
+	})
+	if err != nil {
+		return "", errors.Wrapf(err, "unable to send message to %v", queue)
+	}
+
+	return aws.ToString(out.MessageId), nil
+}
+
 func (p *Provider) PollForNewMessages(ctx context.Context, queue string) ([]*models.Message, error) {
 	out, err := p.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
 		QueueUrl:            aws.String(queue),
diff --git a/internal/sqs-browse/services/messages/iface.go b/internal/sqs-browse/services/messages/iface.go
index 7efd351..bd8beaa 100644
--- a/internal/sqs-browse/services/messages/iface.go
+++ b/internal/sqs-browse/services/messages/iface.go
@@ -6,5 +6,5 @@ import (
 )
 
 type MessageSender interface {
-	SendMessage(ctx context.Context, msg models.Message, queue string) error
+	SendMessage(ctx context.Context, msg models.Message, queue string) (string, error)
 }
\ No newline at end of file
diff --git a/internal/sqs-browse/services/messages/service.go b/internal/sqs-browse/services/messages/service.go
index e9ffc7c..433239f 100644
--- a/internal/sqs-browse/services/messages/service.go
+++ b/internal/sqs-browse/services/messages/service.go
@@ -10,10 +10,16 @@ type Service struct {
 	messageSender MessageSender
 }
 
-func NewService() *Service {
-	return &Service{}
+func NewService(messageSender MessageSender) *Service {
+	return &Service{
+		messageSender: messageSender,
+	}
 }
 
-func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) error {
-	return errors.Wrapf(s.messageSender.SendMessage(ctx, msg, destQueue), "cannot send message to %v", destQueue)
+func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) (string, error) {
+	messageId, err := s.messageSender.SendMessage(ctx, msg, destQueue)
+	if err != nil {
+		return "", errors.Wrapf(err, "cannot send message to %v", destQueue)
+	}
+	return messageId, nil
 }
diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go
index 8c724bb..eb6cc07 100644
--- a/internal/sqs-browse/ui/model.go
+++ b/internal/sqs-browse/ui/model.go
@@ -1,14 +1,28 @@
 package ui
 
 import (
+	"context"
 	table "github.com/calyptia/go-bubble-table"
+	"github.com/charmbracelet/bubbles/textinput"
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/lmika/awstools/internal/common/ui/dispatcher"
+	"github.com/lmika/awstools/internal/common/ui/events"
+	"github.com/lmika/awstools/internal/common/ui/uimodels"
+	"github.com/lmika/awstools/internal/sqs-browse/controllers"
+	"github.com/lmika/awstools/internal/sqs-browse/models"
 	"log"
 	"strings"
 )
 
+var (
+	headerStyle = lipgloss.NewStyle().
+		Bold(true).
+		Foreground(lipgloss.Color("#ffffff")).
+		Background(lipgloss.Color("#eac610"))
+)
+
 type uiModel struct {
 	table    table.Model
 	viewport viewport.Model
@@ -16,17 +30,28 @@ type uiModel struct {
 	ready     bool
 	tableRows []table.Row
 	message   string
+
+	pendingInput *events.PromptForInput
+	textInput    textinput.Model
+
+	dispatcher         *dispatcher.Dispatcher
+	msgSendingHandlers *controllers.MessageSendingController
 }
 
-func NewModel() tea.Model {
+func NewModel(dispatcher *dispatcher.Dispatcher, msgSendingHandlers *controllers.MessageSendingController) tea.Model {
 	tbl := table.New([]string{"seq", "message"}, 100, 20)
 	rows := make([]table.Row, 0)
 	tbl.SetRows(rows)
 
+	textInput := textinput.New()
+
 	model := uiModel{
-		table:     tbl,
-		tableRows: rows,
-		message:   "",
+		table:      tbl,
+		tableRows:  rows,
+		message:    "",
+		textInput:  textInput,
+		msgSendingHandlers: msgSendingHandlers,
+		dispatcher: dispatcher,
 	}
 
 	return model
@@ -37,23 +62,37 @@ func (m uiModel) Init() tea.Cmd {
 }
 
 func (m *uiModel) updateViewportToSelectedMessage() {
-	if !m.ready {
-		return
-	}
-
-	if len(m.tableRows) == 0 {
-		return
-	}
-
-	if message, ok := m.table.SelectedRow().(messageTableRow); ok {
+	if message, ok := m.selectedMessage(); ok {
 		m.viewport.SetContent(message.Data)
 	} else {
 		m.viewport.SetContent("(no message selected)")
 	}
 }
 
+func (m uiModel) selectedMessage() (models.Message, bool) {
+	if m.ready && len(m.tableRows) > 0 {
+		if message, ok := m.table.SelectedRow().(messageTableRow); ok {
+			return models.Message(message), true
+		}
+	}
+	return models.Message{}, false
+}
+
 func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var textInputCommands tea.Cmd
+
 	switch msg := msg.(type) {
+	// Shared messages
+	case events.Error:
+		m.message = "Error: " + msg.Error()
+	case events.Message:
+		m.message = string(msg)
+	case events.PromptForInput:
+		m.textInput.Focus()
+		m.textInput.SetValue("")
+		m.pendingInput = &msg
+
+	// Local messages
 	case NewMessagesEvent:
 		for _, newMsg := range msg {
 			m.tableRows = append(m.tableRows, messageTableRow(*newMsg))
@@ -62,13 +101,13 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.updateViewportToSelectedMessage()
 
 	case tea.WindowSizeMsg:
-		footerHeight := lipgloss.Height(m.footerView())
+		fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView())
 
 		if !m.ready {
 			tableHeight := msg.Height / 2
 
 			m.table.SetSize(msg.Width, tableHeight)
-			m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-footerHeight)
+			m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-fixedViewsHeight)
 			m.viewport.SetContent("(no message selected)")
 			m.ready = true
 			log.Println("Viewport is now ready")
@@ -77,12 +116,29 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 			m.table.SetSize(msg.Width, tableHeight)
 			m.viewport.Width = msg.Width
-			m.viewport.Height = msg.Height - tableHeight - footerHeight
-			//m.viewport.YPosition = tableHeight
+			m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight
 		}
 
+		m.textInput.Width = msg.Width
+
+		m.textInput, textInputCommands = m.textInput.Update(msg)
 	case tea.KeyMsg:
 
+		// If text input in focus, allow that to accept input messages
+		if m.pendingInput != nil {
+			switch msg.String() {
+			case "ctrl+c", "esc":
+				m.pendingInput = nil
+			case "enter":
+				m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
+				m.pendingInput = nil
+			default:
+				m.textInput, textInputCommands = m.textInput.Update(msg)
+			}
+			break
+		}
+
+		// Normal focus
 		switch msg.String() {
 
 		case "ctrl+c", "q":
@@ -93,7 +149,15 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "down", "k":
 			m.table.GoDown()
 			m.updateViewportToSelectedMessage()
+
+		// TODO: these should be moved somewhere else
+		case "f":
+			if selectedMessage, ok := m.selectedMessage(); ok {
+				m.dispatcher.Start(context.Background(), m.msgSendingHandlers.ForwardMessage(selectedMessage))
+			}
 		}
+	default:
+		m.textInput, textInputCommands = m.textInput.Update(msg)
 	}
 
 	updatedTable, tableMsgs := m.table.Update(msg)
@@ -102,7 +166,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	m.table = updatedTable
 	m.viewport = updatedViewport
 
-	return m, tea.Batch(tableMsgs, viewportMsgs)
+	return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs)
 }
 
 func (m uiModel) View() string {
@@ -110,9 +174,35 @@ func (m uiModel) View() string {
 		return "Initializing"
 	}
 
-	log.Println("Returning full view")
-	return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.viewport.View(), m.footerView())
-	//return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.footerView())
+	if m.pendingInput != nil {
+		return lipgloss.JoinVertical(lipgloss.Top,
+			m.headerView(),
+			m.table.View(),
+			m.splitterView(),
+			m.viewport.View(),
+			m.textInput.View(),
+		)
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Top,
+		m.headerView(),
+		m.table.View(),
+		m.splitterView(),
+		m.viewport.View(),
+		m.footerView(),
+	)
+}
+
+func (m uiModel) headerView() string {
+	title := headerStyle.Render("Queue: XXX")
+	line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
+	return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
+}
+
+func (m uiModel) splitterView() string {
+	title := headerStyle.Render("Message")
+	line := headerStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
+	return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
 }
 
 func (m uiModel) footerView() string {