adamdottv 8 месяцев назад
Родитель
Сommit
653965ef59

+ 25 - 13
packages/tui/internal/components/chat/editor.go

@@ -119,6 +119,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.attachments = append(m.attachments, msg.Attachment)
 	case tea.KeyMsg:
 		switch msg.String() {
+		case "ctrl+c":
+			if m.textarea.Value() != "" {
+				m.textarea.Reset()
+				return m, func() tea.Msg {
+					return nil
+				}
+			}
 		case "shift+enter":
 			value := m.textarea.Value()
 			m.textarea.SetValue(value + "\n")
@@ -264,8 +271,12 @@ func (m *editorComponent) View() string {
 	)
 	textarea = styles.BaseStyle().
 		Width(m.width).
-		Border(lipgloss.NormalBorder(), true, true).
-		BorderForeground(t.Border()).
+		PaddingTop(1).
+		PaddingBottom(1).
+		Background(t.BackgroundElement()).
+		Border(lipgloss.ThickBorder(), false, true).
+		BorderForeground(t.BorderActive()).
+		BorderBackground(t.Background()).
 		Render(textarea)
 
 	hint := base("enter") + muted(" send   ") + base("shift") + muted("+") + base("enter") + muted(" newline")
@@ -287,6 +298,7 @@ func (m *editorComponent) View() string {
 	content := lipgloss.JoinVertical(
 		lipgloss.Top,
 		// m.attachmentsContent(),
+		"",
 		textarea,
 		info,
 	)
@@ -409,21 +421,21 @@ func (m *editorComponent) attachmentsContent() string {
 
 func createTextArea(existing *textarea.Model) textarea.Model {
 	t := theme.CurrentTheme()
-	bgColor := t.Background()
+	bgColor := t.BackgroundElement()
 	textColor := t.Text()
 	textMutedColor := t.TextMuted()
 
 	ta := textarea.New()
-	ta.Placeholder = "It's prompting time..."
-
-	ta.Styles.Blurred.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.Styles.Blurred.CursorLine = styles.BaseStyle().Background(bgColor)
-	ta.Styles.Blurred.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	ta.Styles.Blurred.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.Styles.Focused.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.Styles.Focused.CursorLine = styles.BaseStyle().Background(bgColor)
-	ta.Styles.Focused.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	ta.Styles.Focused.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+
+	ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
+	ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
+	ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
+	ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
+	ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
+	ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
+	ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
+	ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
+	ta.Styles.Cursor.Color = t.Primary()
 
 	ta.Prompt = " "
 	ta.ShowLineNumbers = false

+ 2 - 2
packages/tui/internal/components/chat/message.go

@@ -292,8 +292,8 @@ func renderToolInvocation(
 		toolArgs = renderArgs(&toolArgsMap, "filePath")
 		title = fmt.Sprintf("Read: %s   %s", toolArgs, elapsed)
 		body = ""
-		filename := toolArgsMap["filePath"].(string)
-		if metadata["preview"] != nil {
+		if metadata["preview"] != nil && toolArgsMap["filePath"] != nil {
+			filename := toolArgsMap["filePath"].(string)
 			body = metadata["preview"].(string)
 			body = renderFile(filename, body, WithTruncate(6))
 		}

+ 1 - 1
packages/tui/internal/components/chat/messages.go

@@ -237,7 +237,7 @@ func (m *messagesComponent) renderView() {
 	}
 
 	m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
-	m.viewport.SetContent(strings.Join(centered, "\n"))
+	m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
 }
 
 func (m *messagesComponent) header() string {

+ 79 - 160
packages/tui/internal/components/dialog/help.go

@@ -6,196 +6,115 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sst/opencode/internal/components/modal"
 	"github.com/sst/opencode/internal/layout"
-	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
 
-type helpComponent struct {
-	width  int
-	height int
-	keys   []key.Binding
+type helpDialog struct {
+	width    int
+	height   int
+	modal    *modal.Modal
+	bindings []key.Binding
 }
 
-func (h *helpComponent) Init() tea.Cmd {
+// func (i bindingItem) Render(selected bool, width int) string {
+// 	t := theme.CurrentTheme()
+// 	baseStyle := styles.BaseStyle().
+// 		Width(width - 2).
+// 		Background(t.BackgroundElement())
+//
+// 	if selected {
+// 		baseStyle = baseStyle.
+// 			Background(t.Primary()).
+// 			Foreground(t.BackgroundElement()).
+// 			Bold(true)
+// 	} else {
+// 		baseStyle = baseStyle.
+// 			Foreground(t.Text())
+// 	}
+//
+// 	return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
+// }
+
+func (h *helpDialog) Init() tea.Cmd {
 	return nil
 }
 
-func (h *helpComponent) SetBindings(k []key.Binding) {
-	h.keys = k
-}
-
-func (h *helpComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		h.width = 90
+		h.width = msg.Width
 		h.height = msg.Height
 	}
 	return h, nil
 }
 
-func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
-	seen := make(map[string]struct{})
-	result := make([]key.Binding, 0, len(bindings))
-
-	// Process bindings in reverse order
-	for i := len(bindings) - 1; i >= 0; i-- {
-		b := bindings[i]
-		k := strings.Join(b.Keys(), " ")
-		if _, ok := seen[k]; ok {
-			// duplicate, skip
-			continue
-		}
-		seen[k] = struct{}{}
-		// Add to the beginning of result to maintain original order
-		result = append([]key.Binding{b}, result...)
-	}
-
-	return result
-}
-
-func (h *helpComponent) render() string {
+// func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
+// 	seen := make(map[string]struct{})
+// 	result := make([]key.Binding, 0, len(bindings))
+//
+// 	// Process bindings in reverse order
+// 	for i := len(bindings) - 1; i >= 0; i-- {
+// 		b := bindings[i]
+// 		k := strings.Join(b.Keys(), " ")
+// 		if _, ok := seen[k]; ok {
+// 			// duplicate, skip
+// 			continue
+// 		}
+// 		seen[k] = struct{}{}
+// 		// Add to the beginning of result to maintain original order
+// 		result = append([]key.Binding{b}, result...)
+// 	}
+//
+// 	return result
+// }
+
+func (h *helpDialog) View() string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	helpKeyStyle := styles.Bold().
-		Background(t.Background()).
+	keyStyle := lipgloss.NewStyle().
+		Background(t.BackgroundElement()).
 		Foreground(t.Text()).
-		Padding(0, 1, 0, 0)
-
-	helpDescStyle := styles.Regular().
-		Background(t.Background()).
+		Bold(true)
+	descStyle := lipgloss.NewStyle().
+		Background(t.BackgroundElement()).
 		Foreground(t.TextMuted())
 
-	// Compile list of bindings to render
-	bindings := removeDuplicateBindings(h.keys)
-
-	// Enumerate through each group of bindings, populating a series of
-	// pairs of columns, one for keys, one for descriptions
-	var (
-		pairs []string
-		width int
-		rows  = 12 - 2
-	)
-
-	for i := 0; i < len(bindings); i += rows {
-		var (
-			keys  []string
-			descs []string
-		)
-		for j := i; j < min(i+rows, len(bindings)); j++ {
-			keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
-			descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
-		}
-
-		// Render pair of columns; beyond the first pair, render a three space
-		// left margin, in order to visually separate the pairs.
-		var cols []string
-		if len(pairs) > 0 {
-			cols = []string{baseStyle.Render("   ")}
-		}
-
-		maxDescWidth := 0
-		for _, desc := range descs {
-			if maxDescWidth < lipgloss.Width(desc) {
-				maxDescWidth = lipgloss.Width(desc)
-			}
-		}
-		for i := range descs {
-			remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
-			if remainingWidth > 0 {
-				descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
-			}
-		}
-		maxKeyWidth := 0
-		for _, key := range keys {
-			if maxKeyWidth < lipgloss.Width(key) {
-				maxKeyWidth = lipgloss.Width(key)
-			}
-		}
-		for i := range keys {
-			remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
-			if remainingWidth > 0 {
-				keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
+	lines := []string{}
+	for _, b := range h.bindings {
+		content := keyStyle.Render(b.Help().Key)
+		content += descStyle.Render(" " + b.Help().Desc)
+		for i, key := range b.Keys() {
+			if i == 0 {
+				keyString := " (" + strings.ToUpper(key) + ")"
+				// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
+				// spacer := strings.Repeat(" ", space)
+				// content += descStyle.Render(spacer)
+				content += descStyle.Render(keyString)
 			}
 		}
 
-		cols = append(cols,
-			strings.Join(keys, "\n"),
-			strings.Join(descs, "\n"),
-		)
-
-		pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
-		// check whether it exceeds the maximum width avail (the width of the
-		// terminal, subtracting 2 for the borders).
-		width += lipgloss.Width(pair)
-		if width > h.width-2 {
-			break
-		}
-		pairs = append(pairs, pair)
+		lines = append(lines, content)
 	}
 
-	// https://github.com/charmbracelet/lipgloss/v2/issues/209
-	if len(pairs) > 1 {
-		prefix := pairs[:len(pairs)-1]
-		lastPair := pairs[len(pairs)-1]
-		prefix = append(prefix, lipgloss.Place(
-			lipgloss.Width(lastPair),   // width
-			lipgloss.Height(prefix[0]), // height
-			lipgloss.Left,              // x
-			lipgloss.Top,               // y
-			lastPair,                   // content
-			// lipgloss.WithWhitespaceBackground(t.Background()),
-		))
-		content := baseStyle.Width(h.width).Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Top,
-				prefix...,
-			),
-		)
-		return content
-	}
-
-	// Join pairs of columns and enclose in a border
-	content := baseStyle.Width(h.width).Render(
-		lipgloss.JoinHorizontal(
-			lipgloss.Top,
-			pairs...,
-		),
-	)
-	return content
+	return strings.Join(lines, "\n")
 }
 
-func (h *helpComponent) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	content := h.render()
-	header := baseStyle.
-		Bold(true).
-		Width(lipgloss.Width(content)).
-		Foreground(t.Primary()).
-		Render("Keyboard Shortcuts")
+func (h *helpDialog) Render(background string) string {
+	return h.modal.Render(h.View(), background)
+}
 
-	return baseStyle.Padding(1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.TextMuted()).
-		Width(h.width).
-		BorderBackground(t.Background()).
-		Render(
-			lipgloss.JoinVertical(lipgloss.Center,
-				header,
-				baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
-				content,
-			),
-		)
+func (h *helpDialog) Close() tea.Cmd {
+	return nil
 }
 
-type HelpComponent interface {
-	layout.ModelWithView
-	SetBindings([]key.Binding)
+type HelpDialog interface {
+	layout.Modal
 }
 
-func NewHelpCmp() HelpComponent {
-	return &helpComponent{}
+func NewHelpDialog(bindings ...key.Binding) HelpDialog {
+	return &helpDialog{
+		bindings: bindings,
+		modal:    modal.New(),
+	}
 }

+ 52 - 77
packages/tui/internal/components/dialog/models.go

@@ -11,7 +11,9 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/components/modal"
 	"github.com/sst/opencode/internal/layout"
+	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
@@ -19,25 +21,16 @@ import (
 )
 
 const (
-	numVisibleModels = 10
+	numVisibleModels = 6
 	maxDialogWidth   = 40
 )
 
-// CloseModelDialogMsg is sent when a model is selected
-type CloseModelDialogMsg struct {
-	Provider *client.ProviderInfo
-	Model    *client.ProviderModel
-}
-
 // ModelDialog interface for the model selection dialog
 type ModelDialog interface {
-	layout.ModelWithView
-	layout.Bindings
-
-	SetProviders(providers []client.ProviderInfo)
+	layout.Modal
 }
 
-type modelDialogComponent struct {
+type modelDialog struct {
 	app                *app.App
 	availableProviders []client.ProviderInfo
 	provider           client.ProviderInfo
@@ -48,6 +41,8 @@ type modelDialogComponent struct {
 	scrollOffset    int
 	hScrollOffset   int
 	hScrollPossible bool
+
+	modal *modal.Modal
 }
 
 type modelKeyMap struct {
@@ -57,27 +52,23 @@ type modelKeyMap struct {
 	Right  key.Binding
 	Enter  key.Binding
 	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
-	H      key.Binding
-	L      key.Binding
 }
 
 var modelKeys = modelKeyMap{
 	Up: key.NewBinding(
-		key.WithKeys("up"),
+		key.WithKeys("up", "k"),
 		key.WithHelp("↑", "previous model"),
 	),
 	Down: key.NewBinding(
-		key.WithKeys("down"),
+		key.WithKeys("down", "j"),
 		key.WithHelp("↓", "next model"),
 	),
 	Left: key.NewBinding(
-		key.WithKeys("left"),
+		key.WithKeys("left", "h"),
 		key.WithHelp("←", "scroll left"),
 	),
 	Right: key.NewBinding(
-		key.WithKeys("right"),
+		key.WithKeys("right", "l"),
 		key.WithHelp("→", "scroll right"),
 	),
 	Enter: key.NewBinding(
@@ -88,25 +79,9 @@ var modelKeys = modelKeyMap{
 		key.WithKeys("esc"),
 		key.WithHelp("esc", "close"),
 	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next model"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous model"),
-	),
-	H: key.NewBinding(
-		key.WithKeys("h"),
-		key.WithHelp("h", "scroll left"),
-	),
-	L: key.NewBinding(
-		key.WithKeys("l"),
-		key.WithHelp("l", "scroll right"),
-	),
 }
 
-func (m *modelDialogComponent) Init() tea.Cmd {
+func (m *modelDialog) Init() tea.Cmd {
 	// cfg := config.Get()
 	// modelInfo := GetSelectedModel(cfg)
 	// m.availableProviders = getEnabledProviders(cfg)
@@ -116,40 +91,31 @@ func (m *modelDialogComponent) Init() tea.Cmd {
 	// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
 
 	// m.setupModelsForProvider(m.provider)
-
-	m.availableProviders, _ = m.app.ListProviders(context.Background())
-	m.hScrollOffset = 0
-	m.hScrollPossible = len(m.availableProviders) > 1
-	m.provider = m.availableProviders[m.hScrollOffset]
-
 	return nil
 }
 
-func (m *modelDialogComponent) SetProviders(providers []client.ProviderInfo) {
-	m.availableProviders = providers
-}
-
-func (m *modelDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		switch {
-		case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
+		case key.Matches(msg, modelKeys.Up):
 			m.moveSelectionUp()
-		case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
+		case key.Matches(msg, modelKeys.Down):
 			m.moveSelectionDown()
-		case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
+		case key.Matches(msg, modelKeys.Left):
 			if m.hScrollPossible {
 				m.switchProvider(-1)
 			}
-		case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
+		case key.Matches(msg, modelKeys.Right):
 			if m.hScrollPossible {
 				m.switchProvider(1)
 			}
 		case key.Matches(msg, modelKeys.Enter):
 			models := m.models()
-			return m, util.CmdHandler(CloseModelDialogMsg{Provider: &m.provider, Model: &models[m.selectedIdx]})
+			cmd := util.CmdHandler(state.ModelSelectedMsg{Provider: m.provider, Model: models[m.selectedIdx]})
+			return m, tea.Batch(cmd, util.CmdHandler(modal.CloseModalMsg{}))
 		case key.Matches(msg, modelKeys.Escape):
-			return m, util.CmdHandler(CloseModelDialogMsg{})
+			return m, util.CmdHandler(modal.CloseModalMsg{})
 		}
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
@@ -159,7 +125,7 @@ func (m *modelDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m *modelDialogComponent) models() []client.ProviderModel {
+func (m *modelDialog) models() []client.ProviderModel {
 	models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ProviderModel) int {
 		return strings.Compare(*a.Name, *b.Name)
 	})
@@ -167,7 +133,7 @@ func (m *modelDialogComponent) models() []client.ProviderModel {
 }
 
 // moveSelectionUp moves the selection up or wraps to bottom
-func (m *modelDialogComponent) moveSelectionUp() {
+func (m *modelDialog) moveSelectionUp() {
 	if m.selectedIdx > 0 {
 		m.selectedIdx--
 	} else {
@@ -182,7 +148,7 @@ func (m *modelDialogComponent) moveSelectionUp() {
 }
 
 // moveSelectionDown moves the selection down or wraps to top
-func (m *modelDialogComponent) moveSelectionDown() {
+func (m *modelDialog) moveSelectionDown() {
 	if m.selectedIdx < len(m.provider.Models)-1 {
 		m.selectedIdx++
 	} else {
@@ -196,7 +162,7 @@ func (m *modelDialogComponent) moveSelectionDown() {
 	}
 }
 
-func (m *modelDialogComponent) switchProvider(offset int) {
+func (m *modelDialog) switchProvider(offset int) {
 	newOffset := m.hScrollOffset + offset
 
 	// Ensure we stay within bounds
@@ -212,9 +178,11 @@ func (m *modelDialogComponent) switchProvider(offset int) {
 	m.setupModelsForProvider(m.provider.Id)
 }
 
-func (m *modelDialogComponent) View() string {
+func (m *modelDialog) View() string {
 	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
+	baseStyle := lipgloss.NewStyle().
+		Background(t.BackgroundElement()).
+		Foreground(t.Text())
 
 	// Capitalize first letter of provider name
 	title := baseStyle.
@@ -232,8 +200,10 @@ func (m *modelDialogComponent) View() string {
 	for i := m.scrollOffset; i < endIdx; i++ {
 		itemStyle := baseStyle.Width(maxDialogWidth)
 		if i == m.selectedIdx {
-			itemStyle = itemStyle.Background(t.Primary()).
-				Foreground(t.Background()).Bold(true)
+			itemStyle = itemStyle.
+				Background(t.Primary()).
+				Foreground(t.BackgroundElement()).
+				Bold(true)
 		}
 		modelItems = append(modelItems, itemStyle.Render(*models[i].Name))
 	}
@@ -247,15 +217,10 @@ func (m *modelDialogComponent) View() string {
 		scrollIndicator,
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
+	return content
 }
 
-func (m *modelDialogComponent) getScrollIndicators(maxWidth int) string {
+func (m *modelDialog) getScrollIndicators(maxWidth int) string {
 	var indicator string
 
 	if len(m.provider.Models) > numVisibleModels {
@@ -291,10 +256,6 @@ func (m *modelDialogComponent) getScrollIndicators(maxWidth int) string {
 		Render(indicator)
 }
 
-func (m *modelDialogComponent) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(modelKeys)
-}
-
 // findProviderIndex returns the index of the provider in the list, or -1 if not found
 // func findProviderIndex(providers []string, provider string) int {
 // 	for i, p := range providers {
@@ -305,7 +266,7 @@ func (m *modelDialogComponent) BindingKeys() []key.Binding {
 // 	return -1
 // }
 
-func (m *modelDialogComponent) setupModelsForProvider(_ string) {
+func (m *modelDialog) setupModelsForProvider(_ string) {
 	m.selectedIdx = 0
 	m.scrollOffset = 0
 
@@ -331,8 +292,22 @@ func (m *modelDialogComponent) setupModelsForProvider(_ string) {
 	// }
 }
 
-func NewModelDialogCmp(app *app.App) ModelDialog {
-	return &modelDialogComponent{
-		app: app,
+func (m *modelDialog) Render(background string) string {
+	return m.modal.Render(m.View(), background)
+}
+
+func (s *modelDialog) Close() tea.Cmd {
+	return nil
+}
+
+func NewModelDialog(app *app.App) ModelDialog {
+	availableProviders, _ := app.ListProviders(context.Background())
+
+	return &modelDialog{
+		availableProviders: availableProviders,
+		hScrollOffset:      0,
+		hScrollPossible:    len(availableProviders) > 1,
+		provider:           availableProviders[0],
+		modal:              modal.New(),
 	}
 }

+ 40 - 33
packages/tui/internal/components/dialog/quit.go

@@ -6,6 +6,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sst/opencode/internal/components/modal"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
@@ -14,14 +15,17 @@ import (
 
 const question = "Are you sure you want to quit?"
 
-type CloseQuitMsg struct{}
-
+// QuitDialog interface for the quit confirmation dialog
 type QuitDialog interface {
-	layout.ModelWithView
-	layout.Bindings
+	layout.Modal
+	IsQuitDialog() bool
 }
 
-type quitDialogComponent struct {
+type quitDialog struct {
+	width  int
+	height int
+
+	modal      *modal.Modal
 	selectedNo bool
 }
 
@@ -30,12 +34,11 @@ type helpMapping struct {
 	EnterSpace key.Binding
 	Yes        key.Binding
 	No         key.Binding
-	Tab        key.Binding
 }
 
 var helpKeys = helpMapping{
 	LeftRight: key.NewBinding(
-		key.WithKeys("left", "right"),
+		key.WithKeys("left", "right", "h", "l", "tab"),
 		key.WithHelp("←/→", "switch options"),
 	),
 	EnterSpace: key.NewBinding(
@@ -43,58 +46,61 @@ var helpKeys = helpMapping{
 		key.WithHelp("enter/space", "confirm"),
 	),
 	Yes: key.NewBinding(
-		key.WithKeys("y", "Y"),
+		key.WithKeys("y", "Y", "ctrl+c"),
 		key.WithHelp("y/Y", "yes"),
 	),
 	No: key.NewBinding(
 		key.WithKeys("n", "N"),
 		key.WithHelp("n/N", "no"),
 	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
 }
 
-func (q *quitDialogComponent) Init() tea.Cmd {
+func (q *quitDialog) Init() tea.Cmd {
 	return nil
 }
 
-func (q *quitDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		q.width = msg.Width
+		q.height = msg.Height
 	case tea.KeyMsg:
 		switch {
-		case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+		case key.Matches(msg, helpKeys.LeftRight):
 			q.selectedNo = !q.selectedNo
 			return q, nil
 		case key.Matches(msg, helpKeys.EnterSpace):
 			if !q.selectedNo {
 				return q, tea.Quit
 			}
-			return q, util.CmdHandler(CloseQuitMsg{})
+			return q, tea.Batch(
+				util.CmdHandler(modal.CloseModalMsg{}),
+			)
 		case key.Matches(msg, helpKeys.Yes):
 			return q, tea.Quit
 		case key.Matches(msg, helpKeys.No):
-			return q, util.CmdHandler(CloseQuitMsg{})
+			return q, tea.Batch(
+				util.CmdHandler(modal.CloseModalMsg{}),
+			)
 		}
 	}
 	return q, nil
 }
 
-func (q *quitDialogComponent) View() string {
+func (q *quitDialog) Render(background string) string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
 	yesStyle := baseStyle
 	noStyle := baseStyle
-	spacerStyle := baseStyle.Background(t.Background())
+	spacerStyle := baseStyle.Background(t.BackgroundElement())
 
 	if q.selectedNo {
-		noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
-		yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
+		noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
+		yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
 	} else {
-		yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
-		noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
+		yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
+		noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
 	}
 
 	yesButton := yesStyle.Padding(0, 1).Render("Yes")
@@ -117,20 +123,21 @@ func (q *quitDialogComponent) View() string {
 		),
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
+	return q.modal.Render(content, background)
+}
+
+func (q *quitDialog) Close() tea.Cmd {
+	return nil
 }
 
-func (q *quitDialogComponent) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(helpKeys)
+func (q *quitDialog) IsQuitDialog() bool {
+	return true
 }
 
-func NewQuitCmp() QuitDialog {
-	return &quitDialogComponent{
+// NewQuitDialog creates a new quit confirmation dialog
+func NewQuitDialog() QuitDialog {
+	return &quitDialog{
 		selectedNo: true,
+		modal:      modal.New(),
 	}
 }

+ 31 - 130
packages/tui/internal/components/dialog/session.go

@@ -1,29 +1,23 @@
 package dialog
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
+	"context"
+
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/modal"
-	utilComponents "github.com/sst/opencode/internal/components/util"
+	components "github.com/sst/opencode/internal/components/util"
 	"github.com/sst/opencode/internal/layout"
+	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/pkg/client"
 )
 
-// CloseSessionDialogMsg is sent when the session dialog is closed
-type CloseSessionDialogMsg struct {
-	Session *client.SessionInfo
-}
-
 // SessionDialog interface for the session switching dialog
 type SessionDialog interface {
-	tea.Model
-	layout.Bindings
-	SetSessions(sessions []client.SessionInfo)
-	SetSelectedSession(sessionID string)
-	Render(background string) string
+	layout.Modal
 }
 
 type sessionItem struct {
@@ -49,163 +43,70 @@ func (s sessionItem) Render(selected bool, width int) string {
 	return baseStyle.Padding(0, 1).Render(s.session.Title)
 }
 
-// sessionDialogContent is the inner content of the session dialog
-type sessionDialogContent struct {
-	sessions          []client.SessionInfo
+type sessionDialog struct {
 	width             int
 	height            int
+	modal             *modal.Modal
 	selectedSessionID string
-	list              utilComponents.SimpleList[sessionItem]
-}
-
-type sessionKeyMap struct {
-	Enter  key.Binding
-	Escape key.Binding
+	list              components.SimpleList[sessionItem]
 }
 
-var sessionKeys = sessionKeyMap{
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select session"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-}
-
-func (s *sessionDialogContent) Init() tea.Cmd {
+func (s *sessionDialog) Init() tea.Cmd {
 	return nil
 }
 
-func (s *sessionDialogContent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		s.width = msg.Width
 		s.height = msg.Height
+		s.list.SetMaxWidth(layout.Current.Container.Width - 12)
 	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, sessionKeys.Enter):
+		switch msg.String() {
+		case "enter":
 			if item, idx := s.list.GetSelectedItem(); idx >= 0 {
 				selectedSession := item.session
 				s.selectedSessionID = selectedSession.Id
-
-				return s, util.CmdHandler(CloseSessionDialogMsg{
-					Session: &selectedSession,
-				})
+				return s, tea.Batch(
+					util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
+					util.CmdHandler(modal.CloseModalMsg{}),
+				)
 			}
-		case key.Matches(msg, sessionKeys.Escape):
-			return s, util.CmdHandler(CloseSessionDialogMsg{})
-		default:
-			// Pass other key messages to the list component
-			var cmd tea.Cmd
-			listModel, cmd := s.list.Update(msg)
-			s.list = listModel.(utilComponents.SimpleList[sessionItem])
-			return s, cmd
 		}
 	}
 
-	// For non-key messages
 	var cmd tea.Cmd
 	listModel, cmd := s.list.Update(msg)
-	s.list = listModel.(utilComponents.SimpleList[sessionItem])
-	return s, cmd
-}
-
-func (s *sessionDialogContent) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle().Background(t.BackgroundElement())
-	width := layout.Current.Container.Width - 12
-
-	if len(s.sessions) == 0 {
-		return baseStyle.Padding(1, 2).
-			Foreground(t.TextMuted()).
-			Width(width).
-			Render("No sessions available")
-	}
-
-	// Set the max width for the list
-	s.list.SetMaxWidth(width)
-
-	return s.list.View()
-}
-
-func (s *sessionDialogContent) BindingKeys() []key.Binding {
-	// Combine session dialog keys with list keys
-	dialogKeys := layout.KeyMapToSlice(sessionKeys)
-	listKeys := s.list.BindingKeys()
-	return append(dialogKeys, listKeys...)
-}
-
-// sessionDialogComponent wraps the content with a modal
-type sessionDialogComponent struct {
-	content *sessionDialogContent
-	modal   *modal.Modal
-}
-
-func (s *sessionDialogComponent) Init() tea.Cmd {
-	return s.modal.Init()
-}
-
-func (s *sessionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	m, cmd := s.modal.Update(msg)
-	s.modal = m.(*modal.Modal)
+	s.list = listModel.(components.SimpleList[sessionItem])
 	return s, cmd
 }
 
-func (s *sessionDialogComponent) View() string {
-	return s.modal.View()
+func (s *sessionDialog) Render(background string) string {
+	return s.modal.Render(s.list.View(), background)
 }
 
-func (s *sessionDialogComponent) Render(background string) string {
-	return s.modal.Render(background)
-}
-
-func (s *sessionDialogComponent) BindingKeys() []key.Binding {
-	return s.modal.BindingKeys()
+func (s *sessionDialog) Close() tea.Cmd {
+	return nil
 }
 
-func (s *sessionDialogComponent) SetSessions(sessions []client.SessionInfo) {
-	s.content.sessions = sessions
+// NewSessionDialog creates a new session switching dialog
+func NewSessionDialog(app *app.App) SessionDialog {
+	sessions, _ := app.ListSessions(context.Background())
 
-	// Convert sessions to sessionItems
 	var sessionItems []sessionItem
-
 	for _, sess := range sessions {
 		sessionItems = append(sessionItems, sessionItem{session: sess})
 	}
 
-	s.content.list.SetItems(sessionItems)
-}
-
-func (s *sessionDialogComponent) SetSelectedSession(sessionID string) {
-	s.content.selectedSessionID = sessionID
-
-	// Update the selected index if sessions are already loaded
-	if len(s.content.sessions) > 0 {
-		// Re-set the sessions to update the selection
-		s.SetSessions(s.content.sessions)
-	}
-}
-
-// NewSessionDialogCmp creates a new session switching dialog
-func NewSessionDialogCmp() SessionDialog {
-	list := utilComponents.NewSimpleList[sessionItem](
-		[]sessionItem{},
+	list := components.NewSimpleList(
+		sessionItems,
 		10, // maxVisibleSessions
 		"No sessions available",
 		true, // useAlphaNumericKeys
 	)
 
-	content := &sessionDialogContent{
-		sessions:          []client.SessionInfo{},
-		selectedSessionID: "",
-		list:              list,
-	}
-
-	return &sessionDialogComponent{
-		content: content,
-		modal:   modal.New(content, modal.WithTitle("Switch Session")),
+	return &sessionDialog{
+		list:  list,
+		modal: modal.New(modal.WithTitle("Switch Session"), modal.WithMaxWidth(80)),
 	}
 }
-

+ 74 - 145
packages/tui/internal/components/dialog/theme.go

@@ -1,9 +1,9 @@
 package dialog
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sst/opencode/internal/components/modal"
+	components "github.com/sst/opencode/internal/components/util"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/status"
 	"github.com/sst/opencode/internal/styles"
@@ -16,184 +16,113 @@ type ThemeChangedMsg struct {
 	ThemeName string
 }
 
-// CloseThemeDialogMsg is sent when the theme dialog is closed
-type CloseThemeDialogMsg struct{}
-
 // ThemeDialog interface for the theme switching dialog
 type ThemeDialog interface {
-	layout.ModelWithView
-	layout.Bindings
+	layout.Modal
 }
 
-type themeDialogComponent struct {
-	themes       []string
-	selectedIdx  int
-	width        int
-	height       int
-	currentTheme string
+type themeItem struct {
+	name string
 }
 
-type themeKeyMap struct {
-	Up     key.Binding
-	Down   key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
-}
+func (t themeItem) Render(selected bool, width int) string {
+	th := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle().
+		Width(width - 2).
+		Background(th.BackgroundElement())
+
+	if selected {
+		baseStyle = baseStyle.
+			Background(th.Primary()).
+			Foreground(th.BackgroundElement()).
+			Bold(true)
+	} else {
+		baseStyle = baseStyle.
+			Foreground(th.Text())
+	}
 
-var themeKeys = themeKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous theme"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next theme"),
-	),
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select theme"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next theme"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous theme"),
-	),
+	return baseStyle.Padding(0, 1).Render(t.name)
 }
 
-func (t *themeDialogComponent) Init() tea.Cmd {
-	// Load available themes and update selectedIdx based on current theme
-	t.themes = theme.AvailableThemes()
-	t.currentTheme = theme.CurrentThemeName()
+type themeDialog struct {
+	width  int
+	height int
 
-	// Find the current theme in the list
-	for i, name := range t.themes {
-		if name == t.currentTheme {
-			t.selectedIdx = i
-			break
-		}
-	}
+	modal *modal.Modal
+	list  components.SimpleList[themeItem]
+}
 
+func (t *themeDialog) Init() tea.Cmd {
 	return nil
 }
 
-func (t *themeDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		t.width = msg.Width
+		t.height = msg.Height
 	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
-			if t.selectedIdx > 0 {
-				t.selectedIdx--
-			}
-			return t, nil
-		case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
-			if t.selectedIdx < len(t.themes)-1 {
-				t.selectedIdx++
-			}
-			return t, nil
-		case key.Matches(msg, themeKeys.Enter):
-			if len(t.themes) > 0 {
+		switch msg.String() {
+		case "enter":
+			if item, idx := t.list.GetSelectedItem(); idx >= 0 {
 				previousTheme := theme.CurrentThemeName()
-				selectedTheme := t.themes[t.selectedIdx]
+				selectedTheme := item.name
 				if previousTheme == selectedTheme {
-					return t, util.CmdHandler(CloseThemeDialogMsg{})
+					return t, util.CmdHandler(modal.CloseModalMsg{})
 				}
 				if err := theme.SetTheme(selectedTheme); err != nil {
 					status.Error(err.Error())
 					return t, nil
 				}
-				return t, util.CmdHandler(ThemeChangedMsg{
-					ThemeName: selectedTheme,
-				})
+				return t, tea.Batch(
+					util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
+					util.CmdHandler(modal.CloseModalMsg{}),
+				)
 			}
-		case key.Matches(msg, themeKeys.Escape):
-			return t, util.CmdHandler(CloseThemeDialogMsg{})
 		}
-	case tea.WindowSizeMsg:
-		t.width = msg.Width
-		t.height = msg.Height
-	}
-	return t, nil
-}
-
-func (t *themeDialogComponent) View() string {
-	currentTheme := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	if len(t.themes) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(currentTheme.Background()).
-			BorderForeground(currentTheme.TextMuted()).
-			Width(40).
-			Render("No themes available")
 	}
 
-	// Calculate max width needed for theme names
-	maxWidth := 40 // Minimum width
-	for _, themeName := range t.themes {
-		if len(themeName) > maxWidth-4 { // Account for padding
-			maxWidth = len(themeName) + 4
-		}
-	}
+	var cmd tea.Cmd
+	listModel, cmd := t.list.Update(msg)
+	t.list = listModel.(components.SimpleList[themeItem])
+	return t, cmd
+}
 
-	maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
+func (t *themeDialog) Render(background string) string {
+	return t.modal.Render(t.list.View(), background)
+}
 
-	// Build the theme list
-	themeItems := make([]string, 0, len(t.themes))
-	for i, themeName := range t.themes {
-		itemStyle := baseStyle.Width(maxWidth)
+func (t *themeDialog) Close() tea.Cmd {
+	return nil
+}
 
-		if i == t.selectedIdx {
-			itemStyle = itemStyle.
-				Background(currentTheme.Primary()).
-				Foreground(currentTheme.Background()).
-				Bold(true)
+// NewThemeDialog creates a new theme switching dialog
+func NewThemeDialog() ThemeDialog {
+	themes := theme.AvailableThemes()
+	currentTheme := theme.CurrentThemeName()
+
+	var themeItems []themeItem
+	var selectedIdx int
+	for i, name := range themes {
+		themeItems = append(themeItems, themeItem{name: name})
+		if name == currentTheme {
+			selectedIdx = i
 		}
-
-		themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
 	}
 
-	title := baseStyle.
-		Foreground(currentTheme.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Select Theme")
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
-		baseStyle.Width(maxWidth).Render(""),
+	list := components.NewSimpleList(
+		themeItems,
+		10, // maxVisibleThemes
+		"No themes available",
+		true,
 	)
 
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(currentTheme.Background()).
-		BorderForeground(currentTheme.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
+	// Set the initial selection to the current theme
+	list.SetSelectedIndex(selectedIdx)
 
-func (t *themeDialogComponent) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(themeKeys)
-}
-
-// NewThemeDialogCmp creates a new theme switching dialog
-func NewThemeDialogCmp() ThemeDialog {
-	return &themeDialogComponent{
-		themes:       []string{},
-		selectedIdx:  0,
-		currentTheme: "",
+	return &themeDialog{
+		list:  list,
+		modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
 	}
 }
+

+ 41 - 96
packages/tui/internal/components/modal/modal.go

@@ -1,25 +1,23 @@
 package modal
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
 
+// CloseModalMsg is a message to signal that the active modal should be closed.
+type CloseModalMsg struct{}
+
 // Modal is a reusable modal component that handles frame rendering and overlay placement
 type Modal struct {
-	content       tea.Model
-	width         int
-	height        int
-	title         string
-	showBorder    bool
-	borderStyle   lipgloss.Border
-	maxWidth      int
-	maxHeight     int
-	centerContent bool
+	width      int
+	height     int
+	title      string
+	maxWidth   int
+	maxHeight  int
+	fitContent bool
 }
 
 // ModalOption is a function that configures a Modal
@@ -32,24 +30,11 @@ func WithTitle(title string) ModalOption {
 	}
 }
 
-// WithBorder enables/disables the border
-func WithBorder(show bool) ModalOption {
-	return func(m *Modal) {
-		m.showBorder = show
-	}
-}
-
-// WithBorderStyle sets the border style
-func WithBorderStyle(style lipgloss.Border) ModalOption {
-	return func(m *Modal) {
-		m.borderStyle = style
-	}
-}
-
 // WithMaxWidth sets the maximum width
 func WithMaxWidth(width int) ModalOption {
 	return func(m *Modal) {
 		m.maxWidth = width
+		m.fitContent = false
 	}
 }
 
@@ -60,22 +45,18 @@ func WithMaxHeight(height int) ModalOption {
 	}
 }
 
-// WithCenterContent centers the content within the modal
-func WithCenterContent(center bool) ModalOption {
+func WithFitContent(fit bool) ModalOption {
 	return func(m *Modal) {
-		m.centerContent = center
+		m.fitContent = fit
 	}
 }
 
-// New creates a new Modal with the given content and options
-func New(content tea.Model, opts ...ModalOption) *Modal {
+// New creates a new Modal with the given options
+func New(opts ...ModalOption) *Modal {
 	m := &Modal{
-		content:       content,
-		showBorder:    true,
-		borderStyle:   lipgloss.ThickBorder(),
-		maxWidth:      0,
-		maxHeight:     0,
-		centerContent: false,
+		maxWidth:   0,
+		maxHeight:  0,
+		fitContent: true,
 	}
 
 	for _, opt := range opts {
@@ -85,40 +66,24 @@ func New(content tea.Model, opts ...ModalOption) *Modal {
 	return m
 }
 
-func (m *Modal) Init() tea.Cmd {
-	return m.content.Init()
-}
-
-func (m *Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-	}
-
-	// Pass all messages to the content
-	var cmd tea.Cmd
-	m.content, cmd = m.content.Update(msg)
-	return m, cmd
-}
-
-func (m *Modal) View() string {
+// Render renders the modal centered on the screen
+func (m *Modal) Render(contentView string, background string) string {
 	t := theme.CurrentTheme()
-	
-	// Get the content view
-	contentView := ""
-	if v, ok := m.content.(layout.ModelWithView); ok {
-		contentView = v.View()
-	}
 
-	// Calculate dimensions
 	outerWidth := layout.Current.Container.Width - 8
 	if m.maxWidth > 0 && outerWidth > m.maxWidth {
 		outerWidth = m.maxWidth
 	}
-	
+
+	if m.fitContent {
+		titleWidth := lipgloss.Width(m.title)
+		contentWidth := lipgloss.Width(contentView)
+		largestWidth := max(titleWidth+2, contentWidth)
+		outerWidth = largestWidth + 6
+	}
+
 	innerWidth := outerWidth - 4
-	
+
 	// Base style for the modal
 	baseStyle := styles.BaseStyle().
 		Background(t.BackgroundElement()).
@@ -132,7 +97,7 @@ func (m *Modal) View() string {
 			Bold(true).
 			Width(innerWidth).
 			Padding(0, 1)
-		
+
 		titleView := titleStyle.Render(m.title)
 		finalContent = lipgloss.JoinVertical(
 			lipgloss.Left,
@@ -143,56 +108,36 @@ func (m *Modal) View() string {
 		finalContent = contentView
 	}
 
-	// Apply modal styling
 	modalStyle := baseStyle.
 		PaddingTop(1).
 		PaddingBottom(1).
 		PaddingLeft(2).
-		PaddingRight(2)
-
-	if m.showBorder {
-		modalStyle = modalStyle.
-			BorderStyle(m.borderStyle).
-			BorderLeft(true).
-			BorderRight(true).
-			BorderLeftForeground(t.BackgroundSubtle()).
-			BorderLeftBackground(t.Background()).
-			BorderRightForeground(t.BackgroundSubtle()).
-			BorderRightBackground(t.Background())
-	}
-
-	return modalStyle.
+		PaddingRight(2).
+		BorderStyle(lipgloss.ThickBorder()).
+		BorderLeft(true).
+		BorderRight(true).
+		BorderLeftForeground(t.BackgroundSubtle()).
+		BorderLeftBackground(t.Background()).
+		BorderRightForeground(t.BackgroundSubtle()).
+		BorderRightBackground(t.Background())
+
+	modalView := modalStyle.
 		Width(outerWidth).
 		Render(finalContent)
-}
 
-// Render renders the modal centered on the screen
-func (m *Modal) Render(background string) string {
-	modalView := m.View()
-	
 	// Calculate position for centering
 	bgHeight := lipgloss.Height(background)
 	bgWidth := lipgloss.Width(background)
 	modalHeight := lipgloss.Height(modalView)
 	modalWidth := lipgloss.Width(modalView)
-	
+
 	row := (bgHeight - modalHeight) / 2
 	col := (bgWidth - modalWidth) / 2
-	
-	// Use PlaceOverlay to render the modal on top of the background
+
 	return layout.PlaceOverlay(
 		col,
 		row,
 		modalView,
 		background,
-		true, // shadow
 	)
 }
-
-// BindingKeys returns the key bindings from the content if it implements layout.Bindings
-func (m *Modal) BindingKeys() []key.Binding {
-	if b, ok := m.content.(layout.Bindings); ok {
-		return b.BindingKeys()
-	}
-	return []key.Binding{}
-}

+ 7 - 0
packages/tui/internal/components/util/simple-list.go

@@ -20,6 +20,7 @@ type SimpleList[T SimpleListItem] interface {
 	GetSelectedItem() (item T, idx int)
 	SetItems(items []T)
 	GetItems() []T
+	SetSelectedIndex(idx int)
 }
 
 type simpleListComponent[T SimpleListItem] struct {
@@ -109,6 +110,12 @@ func (c *simpleListComponent[T]) SetMaxWidth(width int) {
 	c.maxWidth = width
 }
 
+func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
+	if idx >= 0 && idx < len(c.items) {
+		c.selectedIdx = idx
+	}
+}
+
 func (c *simpleListComponent[T]) View() string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()

+ 6 - 0
packages/tui/internal/layout/layout.go

@@ -36,6 +36,12 @@ type LayoutInfo struct {
 	Container Dimensions
 }
 
+type Modal interface {
+	tea.Model
+	Render(background string) string
+	Close() tea.Cmd
+}
+
 type Focusable interface {
 	Focus() tea.Cmd
 	Blur() tea.Cmd

+ 2 - 39
packages/tui/internal/layout/overlay.go

@@ -8,15 +8,9 @@ import (
 	"github.com/muesli/ansi"
 	"github.com/muesli/reflow/truncate"
 	"github.com/muesli/termenv"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 )
 
-// Most of this code is borrowed from
-// https://github.com/charmbracelet/lipgloss/v2/pull/102
-// as well as the lipgloss library, with some modification for what I needed.
-
 // Split a string into lines, additionally returning the size of the widest line.
 func getLines(s string) (lines []string, widest int) {
 	lines = strings.Split(s, "\n")
@@ -33,42 +27,18 @@ func getLines(s string) (lines []string, widest int) {
 func PlaceOverlay(
 	x, y int,
 	fg, bg string,
-	shadow bool, opts ...WhitespaceOption,
+	opts ...WhitespaceOption,
 ) string {
 	fgLines, fgWidth := getLines(fg)
 	bgLines, bgWidth := getLines(bg)
 	bgHeight := len(bgLines)
 	fgHeight := len(fgLines)
 
-	shadow = false
-
-	if shadow {
-		t := theme.CurrentTheme()
-		baseStyle := styles.BaseStyle()
-
-		var shadowbg string = ""
-		shadowchar := lipgloss.NewStyle().
-			Background(t.BackgroundElement()).
-			Foreground(t.Background()).
-			Render("░")
-		bgchar := baseStyle.Render(" ")
-		for i := 0; i <= fgHeight; i++ {
-			if i == 0 {
-				shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
-			} else {
-				shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
-			}
-		}
-
-		fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
-		fgLines, fgWidth = getLines(fg)
-		fgHeight = len(fgLines)
-	}
-
 	if fgWidth >= bgWidth && fgHeight >= bgHeight {
 		// FIXME: return fg or bg?
 		return fg
 	}
+
 	// TODO: allow placement outside of the bg box?
 	x = util.Clamp(x, 0, bgWidth-fgWidth)
 	y = util.Clamp(y, 0, bgHeight-fgHeight)
@@ -122,13 +92,6 @@ func cutLeft(s string, cutWidth int) string {
 	return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
 }
 
-func max(a, b int) int {
-	if a > b {
-		return a
-	}
-	return b
-}
-
 type whitespace struct {
 	style termenv.Style
 	chars string

+ 9 - 15
packages/tui/internal/page/chat.go

@@ -12,10 +12,8 @@ import (
 	"github.com/sst/opencode/internal/components/chat"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/layout"
-	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/status"
 	"github.com/sst/opencode/internal/util"
-	"github.com/sst/opencode/pkg/client"
 )
 
 var ChatPage PageID = "chat"
@@ -30,17 +28,12 @@ type chatPage struct {
 }
 
 type ChatKeyMap struct {
-	NewSession           key.Binding
 	Cancel               key.Binding
 	ToggleTools          key.Binding
 	ShowCompletionDialog key.Binding
 }
 
 var keyMap = ChatKeyMap{
-	NewSession: key.NewBinding(
-		key.WithKeys("ctrl+n"),
-		key.WithHelp("ctrl+n", "new session"),
-	),
 	Cancel: key.NewBinding(
 		key.WithKeys("esc"),
 		key.WithHelp("esc", "cancel"),
@@ -101,17 +94,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.showCompletionDialog = false
 		p.app.SetCompletionDialogOpen(false)
 	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c":
+			_, cmd := p.editor.Update(msg)
+			if cmd != nil {
+				return p, cmd
+			}
+		}
+
 		switch {
 		case key.Matches(msg, keyMap.ShowCompletionDialog):
 			p.showCompletionDialog = true
 			p.app.SetCompletionDialogOpen(true)
 			// Continue sending keys to layout->chat
-		case key.Matches(msg, keyMap.NewSession):
-			p.app.Session = &client.SessionInfo{}
-			p.app.Messages = []client.MessageInfo{}
-			return p, tea.Batch(
-				util.CmdHandler(state.SessionClearedMsg{}),
-			)
 		case key.Matches(msg, keyMap.Cancel):
 			if p.app.Session.Id != "" {
 				// Cancel the current session's generation process
@@ -173,7 +168,6 @@ func (p *chatPage) View() string {
 			layoutHeight-editorHeight-lipgloss.Height(overlay),
 			overlay,
 			layoutView,
-			false,
 		)
 	}
 
@@ -208,7 +202,7 @@ func NewChatPage(app *app.App) layout.ModelWithView {
 			layout.WithDirection(layout.FlexDirectionVertical),
 			layout.WithPaneSizes(
 				layout.FlexPaneSizeGrow,
-				layout.FlexPaneSizeFixed(6),
+				layout.FlexPaneSizeFixed(5),
 			),
 		),
 	}

+ 122 - 680
packages/tui/internal/tui/tui.go

@@ -3,7 +3,6 @@ package tui
 import (
 	"context"
 	"log/slog"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/cursor"
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -14,6 +13,7 @@ import (
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/core"
 	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/components/modal"
 	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/page"
 	"github.com/sst/opencode/internal/state"
@@ -25,69 +25,41 @@ import (
 )
 
 type keyMap struct {
-	Quit          key.Binding
 	Help          key.Binding
+	NewSession    key.Binding
 	SwitchSession key.Binding
-	Commands      key.Binding
-	Filepicker    key.Binding
-	Models        key.Binding
+	SwitchModel   key.Binding
 	SwitchTheme   key.Binding
-	Tools         key.Binding
+	Quit          key.Binding
 }
 
-const (
-	quitKey = "q"
-)
-
 var keys = keyMap{
-	Quit: key.NewBinding(
-		key.WithKeys("ctrl+c"),
-		key.WithHelp("ctrl+c", "quit"),
-	),
 	Help: key.NewBinding(
-		key.WithKeys("ctrl+_"),
-		key.WithHelp("ctrl+?", "toggle help"),
-	),
-
-	SwitchSession: key.NewBinding(
-		key.WithKeys("ctrl+s"),
-		key.WithHelp("ctrl+s", "switch session"),
+		key.WithKeys("f1", "super+/", "super+h"),
+		key.WithHelp("/help", "show help"),
 	),
-
-	Commands: key.NewBinding(
-		key.WithKeys("ctrl+k"),
-		key.WithHelp("ctrl+k", "commands"),
+	NewSession: key.NewBinding(
+		key.WithKeys("f2", "super+n"),
+		key.WithHelp("/new", "new session"),
 	),
-	Filepicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "select files to upload"),
+	SwitchSession: key.NewBinding(
+		key.WithKeys("f3", "super+s"),
+		key.WithHelp("/sessions", "switch session"),
 	),
-	Models: key.NewBinding(
-		key.WithKeys("ctrl+o"),
-		key.WithHelp("ctrl+o", "model selection"),
+	SwitchModel: key.NewBinding(
+		key.WithKeys("f4", "super+m"),
+		key.WithHelp("/model", "switch model"),
 	),
-
 	SwitchTheme: key.NewBinding(
-		key.WithKeys("ctrl+t"),
-		key.WithHelp("ctrl+t", "switch theme"),
+		key.WithKeys("f5", "super+t"),
+		key.WithHelp("/theme", "switch theme"),
 	),
-
-	Tools: key.NewBinding(
-		key.WithKeys("f9"),
-		key.WithHelp("f9", "show available tools"),
+	Quit: key.NewBinding(
+		key.WithKeys("f10", "ctrl+c", "super+q"),
+		key.WithHelp("/quit", "quit"),
 	),
 }
 
-var helpEsc = key.NewBinding(
-	key.WithKeys("?"),
-	key.WithHelp("?", "toggle help"),
-)
-
-var returnKey = key.NewBinding(
-	key.WithKeys("esc"),
-	key.WithHelp("esc", "close"),
-)
-
 type appModel struct {
 	width, height int
 	currentPage   page.PageID
@@ -96,72 +68,22 @@ type appModel struct {
 	loadedPages   map[page.PageID]bool
 	status        core.StatusComponent
 	app           *app.App
-
-	showPermissions bool
-	permissions     dialog.PermissionDialogComponent
-
-	showHelp bool
-	help     dialog.HelpComponent
-
-	showQuit bool
-	quit     dialog.QuitDialog
-
-	showSessionDialog bool
-	sessionDialog     dialog.SessionDialog
-
-	showCommandDialog bool
-	commandDialog     dialog.CommandDialog
-	commands          []dialog.Command
-
-	showModelDialog bool
-	modelDialog     dialog.ModelDialog
-
-	showInitDialog bool
-	initDialog     dialog.InitDialogCmp
-
-	showFilepicker bool
-	filepicker     dialog.FilepickerComponent
-
-	showThemeDialog bool
-	themeDialog     dialog.ThemeDialog
-
-	showMultiArgumentsDialog bool
-	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
-
-	showToolsDialog bool
-	toolsDialog     dialog.ToolsDialog
+	modal         layout.Modal
+	commands      []dialog.Command
 }
 
 func (a appModel) Init() tea.Cmd {
 	t := theme.CurrentTheme()
 	var cmds []tea.Cmd
 	cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
-	// cmds = append(cmds, tea.SetForegroundColor(t.Background()))
 	cmds = append(cmds, tea.RequestBackgroundColor)
 
 	cmd := a.pages[a.currentPage].Init()
 	a.loadedPages[a.currentPage] = true
 	cmds = append(cmds, cmd)
+
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
-	cmd = a.quit.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.help.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.sessionDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.commandDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.modelDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.initDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.filepicker.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.themeDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.toolsDialog.Init()
-	cmds = append(cmds, cmd)
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
@@ -192,6 +114,41 @@ func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
+
+	if a.modal != nil {
+		isModalTrigger := false
+		if _, ok := msg.(modal.CloseModalMsg); ok {
+			a.modal = nil
+			return a, nil
+		}
+		if msg, ok := msg.(tea.KeyMsg); ok {
+			switch msg.String() {
+			case "esc":
+				a.modal = nil
+				return a, nil
+			case "ctrl+c":
+				if _, ok := a.modal.(dialog.QuitDialog); !ok {
+					quitDialog := dialog.NewQuitDialog()
+					a.modal = quitDialog
+					return a, nil
+				}
+			}
+
+			isModalTrigger = key.Matches(msg, keys.NewSession) ||
+				key.Matches(msg, keys.SwitchSession) ||
+				key.Matches(msg, keys.SwitchModel) ||
+				key.Matches(msg, keys.SwitchTheme) ||
+				key.Matches(msg, keys.Help) ||
+				key.Matches(msg, keys.Quit)
+		}
+
+		if !isModalTrigger {
+			updatedModal, cmd := a.modal.Update(msg)
+			a.modal = updatedModal.(layout.Modal)
+			return a, cmd
+		}
+	}
+
 	switch msg := msg.(type) {
 
 	case tea.BackgroundColorMsg:
@@ -248,39 +205,24 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			},
 		}
 
-		s, _ := a.status.Update(msg)
+		s, cmd := a.status.Update(msg)
 		a.status = s.(core.StatusComponent)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+
 		updated, cmd := a.pages[a.currentPage].Update(msg)
 		a.pages[a.currentPage] = updated.(layout.ModelWithView)
-		cmds = append(cmds, cmd)
-
-		prm, permCmd := a.permissions.Update(msg)
-		a.permissions = prm.(dialog.PermissionDialogComponent)
-		cmds = append(cmds, permCmd)
-
-		help, helpCmd := a.help.Update(msg)
-		a.help = help.(dialog.HelpComponent)
-		cmds = append(cmds, helpCmd)
-
-		session, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = session.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-
-		command, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = command.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-
-		filepicker, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = filepicker.(dialog.FilepickerComponent)
-		cmds = append(cmds, filepickerCmd)
-
-		a.initDialog.SetSize(msg.Width, msg.Height)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 
-		if a.showMultiArgumentsDialog {
-			a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
-			args, argsCmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
+		if a.modal != nil {
+			s, cmd := a.modal.Update(msg)
+			a.modal = s.(layout.Modal)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
 		}
 
 		return a, tea.Batch(cmds...)
@@ -300,36 +242,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// case dialog.PermissionDeny:
 		// 	a.app.Permissions.Deny(context.Background(), msg.Permission)
 		// }
-		a.showPermissions = false
+		// a.showPermissions = false
 		return a, nil
 
 	case page.PageChangeMsg:
 		return a, a.moveToPage(msg.ID)
 
-	case dialog.CloseQuitMsg:
-		a.showQuit = false
-		return a, nil
-
-	case dialog.CloseSessionDialogMsg:
-		a.showSessionDialog = false
-		if msg.Session != nil {
-			return a, util.CmdHandler(state.SessionSelectedMsg(msg.Session))
-		}
-		return a, nil
-
 	case state.SessionSelectedMsg:
 		a.app.Session = msg
 		a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
 		return a.updateAllPages(msg)
 
-	case dialog.CloseModelDialogMsg:
-		a.showModelDialog = false
-		slog.Debug("closing model dialog", "msg", msg)
-		if msg.Provider != nil && msg.Model != nil {
-			return a, util.CmdHandler(state.ModelSelectedMsg{Provider: *msg.Provider, Model: *msg.Model})
-		}
-		return a, nil
-
 	case state.ModelSelectedMsg:
 		a.app.Provider = &msg.Provider
 		a.app.Model = &msg.Model
@@ -338,388 +261,83 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.app.SaveConfig()
 		return a.updateAllPages(msg)
 
-	case dialog.CloseCommandDialogMsg:
-		a.showCommandDialog = false
-		return a, nil
-
-	case dialog.CloseThemeDialogMsg:
-		a.showThemeDialog = false
-		return a, nil
-
-	case dialog.CloseToolsDialogMsg:
-		a.showToolsDialog = false
-		return a, nil
-
-	case dialog.ShowToolsDialogMsg:
-		a.showToolsDialog = msg.Show
-		return a, nil
-
 	case dialog.ThemeChangedMsg:
 		a.app.Config.Theme = msg.ThemeName
 		a.app.SaveConfig()
 
 		updated, cmd := a.pages[a.currentPage].Update(msg)
+		a.pages[a.currentPage] = updated.(layout.ModelWithView)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 
 		t := theme.CurrentTheme()
 		cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
-		// cmds = append(cmds, tea.RequestBackgroundColor)
-
-		a.pages[a.currentPage] = updated.(layout.ModelWithView)
-		a.showThemeDialog = false
-		status.Info("Theme changed to: " + msg.ThemeName)
 		return a, tea.Batch(cmds...)
 
-	case dialog.ShowInitDialogMsg:
-		a.showInitDialog = msg.Show
-		return a, nil
-
-	case dialog.CloseInitDialogMsg:
-		a.showInitDialog = false
-		if msg.Initialize {
-			return a, a.app.InitializeProject(context.Background())
-		} else {
-			// Mark the project as initialized without running the command
-			if err := a.app.MarkProjectInitialized(context.Background()); err != nil {
-				status.Error(err.Error())
-				return a, nil
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c":
+			updated, cmd := a.pages[a.currentPage].Update(msg)
+			a.pages[a.currentPage] = updated.(layout.ModelWithView)
+			if cmd != nil {
+				return a, cmd
 			}
 		}
-		return a, nil
-
-	case dialog.CommandSelectedMsg:
-		a.showCommandDialog = false
-		// Execute the command handler if available
-		if msg.Command.Handler != nil {
-			return a, msg.Command.Handler(msg.Command)
-		}
-		status.Info("Command selected: " + msg.Command.Title)
-		return a, nil
-
-	case dialog.ShowMultiArgumentsDialogMsg:
-		// Show multi-arguments dialog
-		a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
-		a.showMultiArgumentsDialog = true
-		return a, a.multiArgumentsDialog.Init()
 
-	case dialog.CloseMultiArgumentsDialogMsg:
-		// Close multi-arguments dialog
-		a.showMultiArgumentsDialog = false
-
-		// If submitted, replace all named arguments and run the command
-		if msg.Submit {
-			content := msg.Content
-
-			// Replace each named argument with its value
-			for name, value := range msg.Args {
-				placeholder := "$" + name
-				content = strings.ReplaceAll(content, placeholder, value)
-			}
-
-			// Execute the command with arguments
-			return a, util.CmdHandler(dialog.CommandRunCustomMsg{
-				Content: content,
-				Args:    msg.Args,
-			})
-		}
-		return a, nil
+		switch {
+		case key.Matches(msg, keys.Help):
+			helpDialog := dialog.NewHelpDialog(
+				keys.Help,
+				keys.NewSession,
+				keys.SwitchSession,
+				keys.SwitchModel,
+				keys.SwitchTheme,
+				keys.Quit,
+			)
+			a.modal = helpDialog
+			return a, nil
 
-	case tea.KeyMsg:
-		// If multi-arguments dialog is open, let it handle the key press first
-		if a.showMultiArgumentsDialog {
-			args, cmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			return a, cmd
-		}
+		case key.Matches(msg, keys.NewSession):
+			a.app.Session = &client.SessionInfo{}
+			a.app.Messages = []client.MessageInfo{}
+			return a, tea.Batch(
+				util.CmdHandler(state.SessionClearedMsg{}),
+			)
 
-		switch {
-		case key.Matches(msg, keys.Quit):
-			a.showQuit = !a.showQuit
-			if a.showHelp {
-				a.showHelp = false
-			}
-			if a.showSessionDialog {
-				a.showSessionDialog = false
-			}
-			if a.showCommandDialog {
-				a.showCommandDialog = false
-			}
-			if a.showFilepicker {
-				a.showFilepicker = false
-				a.filepicker.ToggleFilepicker(a.showFilepicker)
-				a.app.SetFilepickerOpen(a.showFilepicker)
-			}
-			if a.showModelDialog {
-				a.showModelDialog = false
-			}
-			if a.showMultiArgumentsDialog {
-				a.showMultiArgumentsDialog = false
-			}
-			if a.showToolsDialog {
-				a.showToolsDialog = false
-			}
+		case key.Matches(msg, keys.SwitchModel):
+			modelDialog := dialog.NewModelDialog(a.app)
+			a.modal = modelDialog
 			return a, nil
+
 		case key.Matches(msg, keys.SwitchSession):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
-				// Close other dialogs
-				a.showToolsDialog = false
-				a.showThemeDialog = false
-				a.showModelDialog = false
-				a.showFilepicker = false
-
-				// Load sessions and show the dialog
-				sessions, err := a.app.ListSessions(context.Background())
-				if err != nil {
-					status.Error(err.Error())
-					return a, nil
-				}
-				if len(sessions) == 0 {
-					status.Warn("No sessions available")
-					return a, nil
-				}
-				a.sessionDialog.SetSessions(sessions)
-				a.showSessionDialog = true
-				return a, nil
-			}
+			sessionDialog := dialog.NewSessionDialog(a.app)
+			a.modal = sessionDialog
 			return a, nil
-		case key.Matches(msg, keys.Commands):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
-				// Close other dialogs
-				a.showToolsDialog = false
-				a.showModelDialog = false
-
-				// Show commands dialog
-				if len(a.commands) == 0 {
-					status.Warn("No commands available")
-					return a, nil
-				}
-				a.commandDialog.SetCommands(a.commands)
-				a.showCommandDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.Models):
-			if a.showModelDialog {
-				a.showModelDialog = false
-				return a, nil
-			}
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				// Close other dialogs
-				a.showToolsDialog = false
-				a.showThemeDialog = false
-				a.showFilepicker = false
-
-				// Load providers and show the dialog
-				providers, err := a.app.ListProviders(context.Background())
-				if err != nil {
-					status.Error(err.Error())
-					return a, nil
-				}
-				if len(providers) == 0 {
-					status.Warn("No providers available")
-					return a, nil
-				}
-				a.modelDialog.SetProviders(providers)
 
-				a.showModelDialog = true
-				return a, nil
-			}
-			return a, nil
 		case key.Matches(msg, keys.SwitchTheme):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				// Close other dialogs
-				a.showToolsDialog = false
-				a.showModelDialog = false
-				a.showFilepicker = false
-
-				a.showThemeDialog = true
-				return a, a.themeDialog.Init()
-			}
+			themeDialog := dialog.NewThemeDialog()
+			a.modal = themeDialog
 			return a, nil
-		case key.Matches(msg, keys.Tools):
-			// Check if any other dialog is open
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions &&
-				!a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog &&
-				!a.showFilepicker && !a.showModelDialog && !a.showInitDialog &&
-				!a.showMultiArgumentsDialog {
-				// Toggle tools dialog
-				a.showToolsDialog = !a.showToolsDialog
-				if a.showToolsDialog {
-					// Get tool names dynamically
-					toolNames := getAvailableToolNames(a.app)
-					a.toolsDialog.SetTools(toolNames)
-				}
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, returnKey) || key.Matches(msg):
-			if !a.filepicker.IsCWDFocused() {
-				if a.showToolsDialog {
-					a.showToolsDialog = false
-					return a, nil
-				}
-				if a.showQuit {
-					a.showQuit = !a.showQuit
-					return a, nil
-				}
-				if a.showHelp {
-					a.showHelp = !a.showHelp
-					return a, nil
-				}
-				if a.showInitDialog {
-					a.showInitDialog = false
-					// TODO: should we not ask again?
-					// Mark the project as initialized without running the command
-					// if err := config.MarkProjectInitialized(); err != nil {
-					// 	status.Error(err.Error())
-					// 	return a, nil
-					// }
-					return a, nil
-				}
-				if a.showFilepicker {
-					a.showFilepicker = false
-					a.filepicker.ToggleFilepicker(a.showFilepicker)
-					a.app.SetFilepickerOpen(a.showFilepicker)
-					return a, nil
-				}
-			}
-		case key.Matches(msg, keys.Help):
-			if a.showQuit {
-				return a, nil
-			}
-			a.showHelp = !a.showHelp
 
-			// Close other dialogs if opening help
-			if a.showHelp {
-				a.showToolsDialog = false
-			}
-			return a, nil
-		case key.Matches(msg, helpEsc):
-			if a.app.IsBusy() {
-				if a.showQuit {
-					return a, nil
-				}
-				a.showHelp = !a.showHelp
-				return a, nil
-			}
-		case key.Matches(msg, keys.Filepicker):
-			// Toggle filepicker
-			a.showFilepicker = !a.showFilepicker
-			a.filepicker.ToggleFilepicker(a.showFilepicker)
-			a.app.SetFilepickerOpen(a.showFilepicker)
-			// Close other dialogs if opening filepicker
-			if a.showFilepicker {
-				a.showToolsDialog = false
-				a.showThemeDialog = false
-				a.showModelDialog = false
-				a.showCommandDialog = false
-				a.showSessionDialog = false
-			}
+		case key.Matches(msg, keys.Quit):
+			quitDialog := dialog.NewQuitDialog()
+			a.modal = quitDialog
 			return a, nil
 		}
 
 	default:
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerComponent)
-		cmds = append(cmds, filepickerCmd)
-	}
-
-	if a.showFilepicker {
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerComponent)
-		cmds = append(cmds, filepickerCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showQuit {
-		q, quitCmd := a.quit.Update(msg)
-		a.quit = q.(dialog.QuitDialog)
-		cmds = append(cmds, quitCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showPermissions {
-		d, permissionsCmd := a.permissions.Update(msg)
-		a.permissions = d.(dialog.PermissionDialogComponent)
-		cmds = append(cmds, permissionsCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showSessionDialog {
-		d, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = d.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showCommandDialog {
-		d, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = d.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showModelDialog {
-		d, modelCmd := a.modelDialog.Update(msg)
-		a.modelDialog = d.(dialog.ModelDialog)
-		cmds = append(cmds, modelCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showInitDialog {
-		d, initCmd := a.initDialog.Update(msg)
-		a.initDialog = d.(dialog.InitDialogCmp)
-		cmds = append(cmds, initCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showThemeDialog {
-		d, themeCmd := a.themeDialog.Update(msg)
-		a.themeDialog = d.(dialog.ThemeDialog)
-		cmds = append(cmds, themeCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-
-	if a.showToolsDialog {
-		d, toolsCmd := a.toolsDialog.Update(msg)
-		a.toolsDialog = d.(dialog.ToolsDialog)
-		cmds = append(cmds, toolsCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
+		// f, filepickerCmd := a.filepicker.Update(msg)
+		// a.filepicker = f.(dialog.FilepickerComponent)
+		// cmds = append(cmds, filepickerCmd)
 	}
 
+	// update status bar
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
 	a.status = s.(core.StatusComponent)
 
+	// update current page
 	updated, cmd := a.pages[a.currentPage].Update(msg)
 	a.pages[a.currentPage] = updated.(layout.ModelWithView)
 	cmds = append(cmds, cmd)
@@ -731,12 +349,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
 	a.commands = append(a.commands, cmd)
 }
 
-// getAvailableToolNames returns a list of all available tool names
-func getAvailableToolNames(_ *app.App) []string {
-	// TODO: Tools not implemented in API yet
-	return []string{"Tools not available in API mode"}
-}
-
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	var cmds []tea.Cmd
 	if _, ok := a.loadedPages[pageID]; !ok {
@@ -759,170 +371,10 @@ func (a appModel) View() string {
 		a.pages[a.currentPage].View(),
 	}
 	components = append(components, a.status.View())
-
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
 
-	if a.showPermissions {
-		overlay := a.permissions.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showFilepicker {
-		overlay := a.filepicker.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-
-	}
-
-	if a.showHelp {
-		bindings := layout.KeyMapToSlice(keys)
-		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
-			bindings = append(bindings, p.BindingKeys()...)
-		}
-		if a.showPermissions {
-			bindings = append(bindings, a.permissions.BindingKeys()...)
-		}
-		if !a.app.IsBusy() {
-			bindings = append(bindings, helpEsc)
-		}
-		a.help.SetBindings(bindings)
-
-		overlay := a.help.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showQuit {
-		overlay := a.quit.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showSessionDialog {
-		appView = a.sessionDialog.Render(appView)
-	}
-
-	if a.showModelDialog {
-		overlay := a.modelDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showCommandDialog {
-		overlay := a.commandDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showInitDialog {
-		overlay := a.initDialog.View()
-		appView = layout.PlaceOverlay(
-			a.width/2-lipgloss.Width(overlay)/2,
-			a.height/2-lipgloss.Height(overlay)/2,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showThemeDialog {
-		overlay := a.themeDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showMultiArgumentsDialog {
-		overlay := a.multiArgumentsDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showToolsDialog {
-		overlay := a.toolsDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
+	if a.modal != nil {
+		appView = a.modal.Render(appView)
 	}
 
 	return appView
@@ -931,24 +383,14 @@ func (a appModel) View() string {
 func NewModel(app *app.App) tea.Model {
 	startPage := page.ChatPage
 	model := &appModel{
-		currentPage:   startPage,
-		loadedPages:   make(map[page.PageID]bool),
-		status:        core.NewStatusCmp(app),
-		help:          dialog.NewHelpCmp(),
-		quit:          dialog.NewQuitCmp(),
-		sessionDialog: dialog.NewSessionDialogCmp(),
-		commandDialog: dialog.NewCommandDialogCmp(),
-		modelDialog:   dialog.NewModelDialogCmp(app),
-		permissions:   dialog.NewPermissionDialogCmp(),
-		initDialog:    dialog.NewInitDialogCmp(),
-		themeDialog:   dialog.NewThemeDialogCmp(),
-		toolsDialog:   dialog.NewToolsDialogCmp(),
-		app:           app,
-		commands:      []dialog.Command{},
+		currentPage: startPage,
+		loadedPages: make(map[page.PageID]bool),
+		status:      core.NewStatusCmp(app),
+		app:         app,
+		commands:    []dialog.Command{},
 		pages: map[page.PageID]layout.ModelWithView{
 			page.ChatPage: page.NewChatPage(app),
 		},
-		filepicker: dialog.NewFilepickerCmp(app),
 	}
 
 	model.RegisterCommand(dialog.Command{