Explorar o código

chore: consolidate chat page into tui.go

adamdottv hai 8 meses
pai
achega
a5da5127fa

+ 11 - 4
packages/tui/internal/app/app.go

@@ -11,7 +11,6 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/config"
-	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/pkg/client"
@@ -32,6 +31,14 @@ type App struct {
 	Commands   commands.Registry
 }
 
+type SessionSelectedMsg = *client.SessionInfo
+type ModelSelectedMsg struct {
+	Provider client.ProviderInfo
+	Model    client.ModelInfo
+}
+type SessionClearedMsg struct{}
+type CompactSessionMsg struct{}
+
 func New(
 	ctx context.Context,
 	version string,
@@ -118,7 +125,7 @@ func (a *App) InitializeProvider() tea.Cmd {
 		}
 
 		// TODO: handle no provider or model setup, yet
-		return state.ModelSelectedMsg{
+		return ModelSelectedMsg{
 			Provider: *currentProvider,
 			Model:    *currentModel,
 		}
@@ -167,7 +174,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
 	}
 
 	a.Session = session
-	cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
+	cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
 
 	go func() {
 		response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
@@ -236,7 +243,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
 			return nil
 		}
 		a.Session = session
-		cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
+		cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
 	}
 
 	// TODO: Handle attachments when API supports them

+ 9 - 4
packages/tui/internal/components/chat/editor.go

@@ -16,12 +16,17 @@ import (
 	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/image"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
 )
 
+type EditorComponent interface {
+	tea.Model
+	tea.ViewModel
+	Value() string
+}
+
 type editorComponent struct {
 	width          int
 	height         int
@@ -99,7 +104,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
 	switch msg := msg.(type) {
-	case dialog.ThemeChangedMsg:
+	case dialog.ThemeSelectedMsg:
 		m.textarea = createTextArea(&m.textarea)
 		m.spinner = createSpinner()
 		return m, m.spinner.Tick
@@ -434,11 +439,11 @@ func createSpinner() spinner.Model {
 	)
 }
 
-func (m *editorComponent) GetValue() string {
+func (m *editorComponent) Value() string {
 	return m.textarea.Value()
 }
 
-func NewEditorComponent(app *app.App) layout.ModelWithView {
+func NewEditorComponent(app *app.App) EditorComponent {
 	s := createSpinner()
 	ta := createTextArea(nil)
 

+ 15 - 6
packages/tui/internal/components/chat/messages.go

@@ -12,12 +12,16 @@ import (
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/dialog"
 	"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/pkg/client"
 )
 
+type MessagesComponent interface {
+	tea.Model
+	tea.ViewModel
+}
+
 type messagesComponent struct {
 	app             *app.App
 	width, height   int
@@ -69,7 +73,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.viewport.GotoBottom()
 		m.tail = true
 		return m, nil
-	case dialog.ThemeChangedMsg:
+	case dialog.ThemeSelectedMsg:
 		m.cache.Clear()
 		m.renderView()
 		return m, nil
@@ -77,12 +81,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.showToolResults = !m.showToolResults
 		m.renderView()
 		return m, nil
-	case state.SessionSelectedMsg:
+	case app.SessionSelectedMsg:
 		m.cache.Clear()
 		cmd := m.Reload()
 		m.viewport.GotoBottom()
 		return m, cmd
-	case state.SessionClearedMsg:
+	case app.SessionClearedMsg:
 		m.cache.Clear()
 		cmd := m.Reload()
 		return m, cmd
@@ -101,7 +105,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.tail {
 			m.viewport.GotoBottom()
 		}
-	case state.StateUpdatedMsg:
+	case client.EventSessionUpdated:
+		m.renderView()
+		if m.tail {
+			m.viewport.GotoBottom()
+		}
+	case client.EventMessageUpdated:
 		m.renderView()
 		if m.tail {
 			m.viewport.GotoBottom()
@@ -389,7 +398,7 @@ func (m *messagesComponent) Reload() tea.Cmd {
 	}
 }
 
-func NewMessagesComponent(app *app.App) layout.ModelWithView {
+func NewMessagesComponent(app *app.App) MessagesComponent {
 	customSpinner := spinner.Spinner{
 		Frames: []string{" ", "┃", "┃"},
 		FPS:    time.Second / 3,

+ 2 - 2
packages/tui/internal/components/dialog/complete.go

@@ -6,7 +6,6 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/components/list"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
@@ -76,7 +75,8 @@ type CompletionDialogCompleteItemMsg struct {
 type CompletionDialogCloseMsg struct{}
 
 type CompletionDialog interface {
-	layout.ModelWithView
+	tea.Model
+	tea.ViewModel
 	SetWidth(width int)
 	IsEmpty() bool
 	SetProvider(provider CompletionProvider)

+ 1 - 2
packages/tui/internal/components/dialog/models.go

@@ -13,7 +13,6 @@ import (
 	"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"
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, tea.Sequence(
 				util.CmdHandler(modal.CloseModalMsg{}),
 				util.CmdHandler(
-					state.ModelSelectedMsg{
+					app.ModelSelectedMsg{
 						Provider: m.provider,
 						Model:    models[m.selectedIdx],
 					}),

+ 2 - 2
packages/tui/internal/components/dialog/permission.go

@@ -6,7 +6,6 @@ import (
 	"github.com/charmbracelet/bubbles/v2/viewport"
 	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"
 	"github.com/sst/opencode/internal/util"
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
 
 // PermissionDialogComponent interface for permission dialog component
 type PermissionDialogComponent interface {
-	layout.ModelWithView
+	tea.Model
+	tea.ViewModel
 	// SetPermissions(permission permission.PermissionRequest) tea.Cmd
 }
 

+ 1 - 2
packages/tui/internal/components/dialog/session.go

@@ -8,7 +8,6 @@ import (
 	"github.com/sst/opencode/internal/components/list"
 	"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"
@@ -69,7 +68,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				s.selectedSessionID = selectedSession.Id
 				return s, tea.Sequence(
 					util.CmdHandler(modal.CloseModalMsg{}),
-					util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
+					util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
 				)
 			}
 		}

+ 3 - 3
packages/tui/internal/components/dialog/theme.go

@@ -10,8 +10,8 @@ import (
 	"github.com/sst/opencode/internal/util"
 )
 
-// ThemeChangedMsg is sent when the theme is changed
-type ThemeChangedMsg struct {
+// ThemeSelectedMsg is sent when the theme is changed
+type ThemeSelectedMsg struct {
 	ThemeName string
 }
 
@@ -75,7 +75,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 				return t, tea.Sequence(
 					util.CmdHandler(modal.CloseModalMsg{}),
-					util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
+					util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
 				)
 			}
 		}

+ 2 - 2
packages/tui/internal/components/list/list.go

@@ -4,7 +4,6 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/sst/opencode/internal/layout"
 )
 
 type ListItem interface {
@@ -12,7 +11,8 @@ type ListItem interface {
 }
 
 type List[T ListItem] interface {
-	layout.ModelWithView
+	tea.Model
+	tea.ViewModel
 	SetMaxWidth(maxWidth int)
 	GetSelectedItem() (item T, idx int)
 	SetItems(items []T)

+ 3 - 3
packages/tui/internal/components/core/status.go → packages/tui/internal/components/status/status.go

@@ -1,4 +1,4 @@
-package core
+package status
 
 import (
 	"fmt"
@@ -7,13 +7,13 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 )
 
 type StatusComponent interface {
-	layout.ModelWithView
+	tea.Model
+	tea.ViewModel
 }
 
 type statusComponent struct {

+ 29 - 27
packages/tui/internal/layout/container.go

@@ -6,20 +6,14 @@ import (
 	"github.com/sst/opencode/internal/theme"
 )
 
-type ModelWithView interface {
+type Container interface {
 	tea.Model
 	tea.ViewModel
-}
-
-type Container interface {
-	ModelWithView
 	Sizeable
-	Focus()
-	Blur()
+	Focusable
 	MaxWidth() int
 	Alignment() lipgloss.Position
 	GetPosition() (x, y int)
-	GetContent() ModelWithView
 }
 
 type container struct {
@@ -28,7 +22,7 @@ type container struct {
 	x      int
 	y      int
 
-	content ModelWithView
+	content tea.ViewModel
 
 	paddingTop    int
 	paddingRight  int
@@ -48,13 +42,19 @@ type container struct {
 }
 
 func (c *container) Init() tea.Cmd {
-	return c.content.Init()
+	if model, ok := c.content.(tea.Model); ok {
+		return model.Init()
+	}
+	return nil
 }
 
 func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	u, cmd := c.content.Update(msg)
-	c.content = u.(ModelWithView)
-	return c, cmd
+	if model, ok := c.content.(tea.Model); ok {
+		u, cmd := model.Update(msg)
+		c.content = u.(tea.ViewModel)
+		return c, cmd
+	}
+	return c, nil
 }
 
 func (c *container) View() string {
@@ -156,21 +156,28 @@ func (c *container) Alignment() lipgloss.Position {
 }
 
 // Focus sets the container as focused
-func (c *container) Focus() {
+func (c *container) Focus() tea.Cmd {
 	c.focused = true
-	// Pass focus to content if it supports it
-	if focusable, ok := c.content.(interface{ Focus() }); ok {
-		focusable.Focus()
+	if focusable, ok := c.content.(Focusable); ok {
+		return focusable.Focus()
 	}
+	return nil
 }
 
 // Blur removes focus from the container
-func (c *container) Blur() {
+func (c *container) Blur() tea.Cmd {
 	c.focused = false
-	// Remove focus from content if it supports it
-	if blurable, ok := c.content.(interface{ Blur() }); ok {
-		blurable.Blur()
+	if blurable, ok := c.content.(Focusable); ok {
+		return blurable.Blur()
 	}
+	return nil
+}
+
+func (c *container) IsFocused() bool {
+	if blurable, ok := c.content.(Focusable); ok {
+		return blurable.IsFocused()
+	}
+	return c.focused
 }
 
 // GetPosition returns the x, y coordinates of the container
@@ -178,14 +185,9 @@ func (c *container) GetPosition() (x, y int) {
 	return c.x, c.y
 }
 
-// GetContent returns the content of the container
-func (c *container) GetContent() ModelWithView {
-	return c.content
-}
-
 type ContainerOption func(*container)
 
-func NewContainer(content ModelWithView, options ...ContainerOption) Container {
+func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
 	c := &container{
 		content:     content,
 		borderStyle: lipgloss.NormalBorder(),

+ 2 - 1
packages/tui/internal/layout/flex.go

@@ -25,7 +25,8 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
 }
 
 type FlexLayout interface {
-	ModelWithView
+	tea.Model
+	tea.ViewModel
 	Sizeable
 	SetPanes(panes []Container) tea.Cmd
 	SetPaneSizes(sizes []FlexPaneSize) tea.Cmd

+ 0 - 187
packages/tui/internal/page/chat.go

@@ -1,187 +0,0 @@
-package page
-
-import (
-	"context"
-
-	"github.com/charmbracelet/bubbles/v2/key"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/completions"
-	"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/util"
-)
-
-var ChatPage PageID = "chat"
-
-type chatPage struct {
-	app                  *app.App
-	editor               layout.Container
-	messages             layout.Container
-	layout               layout.FlexLayout
-	completionDialog     dialog.CompletionDialog
-	completionManager    *completions.CompletionManager
-	showCompletionDialog bool
-}
-
-type ChatKeyMap struct {
-	Cancel               key.Binding
-	ToggleTools          key.Binding
-	ShowCompletionDialog key.Binding
-}
-
-var keyMap = ChatKeyMap{
-	Cancel: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "cancel"),
-	),
-	ToggleTools: key.NewBinding(
-		key.WithKeys("ctrl+h"),
-		key.WithHelp("ctrl+h", "toggle tools"),
-	),
-	ShowCompletionDialog: key.NewBinding(
-		key.WithKeys("/"),
-		key.WithHelp("/", "Complete"),
-	),
-}
-
-func (p *chatPage) Init() tea.Cmd {
-	cmds := []tea.Cmd{
-		p.layout.Init(),
-	}
-	cmds = append(cmds, p.completionDialog.Init())
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		cmd := p.layout.SetSize(msg.Width, msg.Height)
-		cmds = append(cmds, cmd)
-	case chat.SendMsg:
-		p.showCompletionDialog = false
-		cmd := p.sendMessage(msg.Text, msg.Attachments)
-		if cmd != nil {
-			return p, cmd
-		}
-	case dialog.CompletionDialogCloseMsg:
-		p.showCompletionDialog = 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
-			// Continue sending keys to layout->chat
-		case key.Matches(msg, keyMap.Cancel):
-			if p.app.Session.Id != "" {
-				// Cancel the current session's generation process
-				// This allows users to interrupt long-running operations
-				p.app.Cancel(context.Background(), p.app.Session.Id)
-				return p, nil
-			}
-		case key.Matches(msg, keyMap.ToggleTools):
-			return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
-		}
-	}
-
-	if p.showCompletionDialog {
-		// Get the current text from the editor to determine which provider to use
-		editorModel := p.editor.GetContent().(interface{ GetValue() string })
-		currentInput := editorModel.GetValue()
-		provider := p.completionManager.GetProvider(currentInput)
-		p.completionDialog.SetProvider(provider)
-
-		context, contextCmd := p.completionDialog.Update(msg)
-		p.completionDialog = context.(dialog.CompletionDialog)
-		cmds = append(cmds, contextCmd)
-
-		// Doesn't forward event if enter key is pressed
-		if keyMsg, ok := msg.(tea.KeyMsg); ok {
-			if keyMsg.String() == "enter" {
-				return p, tea.Batch(cmds...)
-			}
-		}
-	}
-
-	u, cmd := p.layout.Update(msg)
-	cmds = append(cmds, cmd)
-	p.layout = u.(layout.FlexLayout)
-	return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
-	var cmds []tea.Cmd
-	cmd := p.app.SendChatMessage(context.Background(), text, attachments)
-	cmds = append(cmds, cmd)
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	return p.layout.SetSize(width, height)
-}
-
-func (p *chatPage) GetSize() (int, int) {
-	return p.layout.GetSize()
-}
-
-func (p *chatPage) View() string {
-	layoutView := p.layout.View()
-
-	if p.showCompletionDialog {
-		editorWidth, _ := p.editor.GetSize()
-		editorX, editorY := p.editor.GetPosition()
-
-		p.completionDialog.SetWidth(editorWidth)
-		overlay := p.completionDialog.View()
-
-		layoutView = layout.PlaceOverlay(
-			editorX,
-			editorY-lipgloss.Height(overlay)+2,
-			overlay,
-			layoutView,
-		)
-	}
-
-	return layoutView
-}
-
-func NewChatPage(app *app.App) layout.ModelWithView {
-	completionManager := completions.NewCompletionManager(app)
-	initialProvider := completionManager.GetProvider("")
-	completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
-
-	messagesContainer := layout.NewContainer(
-		chat.NewMessagesComponent(app),
-	)
-	editorContainer := layout.NewContainer(
-		chat.NewEditorComponent(app),
-		layout.WithMaxWidth(layout.Current.Container.Width),
-		layout.WithAlignCenter(),
-	)
-
-	return &chatPage{
-		app:               app,
-		editor:            editorContainer,
-		messages:          messagesContainer,
-		completionDialog:  completionDialog,
-		completionManager: completionManager,
-		layout: layout.NewFlexLayout(
-			layout.WithPanes(messagesContainer, editorContainer),
-			layout.WithDirection(layout.FlexDirectionVertical),
-			layout.WithPaneSizes(
-				layout.FlexPaneSizeGrow,
-				layout.FlexPaneSizeFixed(6),
-			),
-		),
-	}
-}

+ 0 - 8
packages/tui/internal/page/page.go

@@ -1,8 +0,0 @@
-package page
-
-type PageID string
-
-// PageChangeMsg is used to change the current page
-type PageChangeMsg struct {
-	ID PageID
-}

+ 0 - 19
packages/tui/internal/state/state.go

@@ -1,19 +0,0 @@
-package state
-
-import (
-	"github.com/sst/opencode/pkg/client"
-)
-
-type SessionSelectedMsg = *client.SessionInfo
-type ModelSelectedMsg struct {
-	Provider client.ProviderInfo
-	Model    client.ModelInfo
-}
-
-type SessionClearedMsg struct{}
-type CompactSessionMsg struct{}
-
-// TODO: remove
-type StateUpdatedMsg struct {
-	State map[string]any
-}

+ 0 - 12
packages/tui/internal/styles/icons.go

@@ -1,12 +0,0 @@
-package styles
-
-const (
-	OpenCodeIcon string = "◍"
-
-	ErrorIcon    string = "ⓔ"
-	WarningIcon  string = "ⓦ"
-	InfoIcon     string = "ⓘ"
-	HintIcon     string = "ⓗ"
-	SpinnerIcon  string = "⟳"
-	DocumentIcon string = "🖼"
-)

+ 156 - 88
packages/tui/internal/tui/tui.go

@@ -12,12 +12,12 @@ import (
 
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/commands"
-	"github.com/sst/opencode/internal/components/core"
+	"github.com/sst/opencode/internal/completions"
+	"github.com/sst/opencode/internal/components/chat"
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/modal"
+	"github.com/sst/opencode/internal/components/status"
 	"github.com/sst/opencode/internal/layout"
-	"github.com/sst/opencode/internal/page"
-	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
@@ -25,14 +25,38 @@ import (
 )
 
 type appModel struct {
-	width, height int
-	currentPage   page.PageID
-	previousPage  page.PageID
-	pages         map[page.PageID]layout.ModelWithView
-	loadedPages   map[page.PageID]bool
-	status        core.StatusComponent
-	app           *app.App
-	modal         layout.Modal
+	width, height        int
+	status               status.StatusComponent
+	app                  *app.App
+	modal                layout.Modal
+	editorContainer      layout.Container
+	editor               chat.EditorComponent
+	messagesContainer    layout.Container
+	layout               layout.FlexLayout
+	completionDialog     dialog.CompletionDialog
+	completionManager    *completions.CompletionManager
+	showCompletionDialog bool
+}
+
+type ChatKeyMap struct {
+	Cancel               key.Binding
+	ToggleTools          key.Binding
+	ShowCompletionDialog key.Binding
+}
+
+var keyMap = ChatKeyMap{
+	Cancel: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "cancel"),
+	),
+	ToggleTools: key.NewBinding(
+		key.WithKeys("ctrl+h"),
+		key.WithHelp("ctrl+h", "toggle tools"),
+	),
+	ShowCompletionDialog: key.NewBinding(
+		key.WithKeys("/"),
+		key.WithHelp("/", "Complete"),
+	),
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -43,12 +67,9 @@ func (a appModel) Init() tea.Cmd {
 	cmds = append(cmds, tea.SetBackgroundColor(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)
+	cmds = append(cmds, a.layout.Init())
+	cmds = append(cmds, a.completionDialog.Init())
+	cmds = append(cmds, a.status.Init())
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
@@ -59,23 +80,6 @@ func (a appModel) Init() tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
-func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	var cmd tea.Cmd
-
-	for id := range a.pages {
-		updated, cmd := a.pages[id].Update(msg)
-		a.pages[id] = updated.(layout.ModelWithView)
-		cmds = append(cmds, cmd)
-	}
-
-	s, cmd := a.status.Update(msg)
-	cmds = append(cmds, cmd)
-	a.status = s.(core.StatusComponent)
-
-	return a, tea.Batch(cmds...)
-}
-
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
@@ -97,6 +101,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, tea.Quit
 			}
 
+			// TODO: do we need this?
 			// don't send commands to the modal
 			for _, cmdDef := range a.app.Commands {
 				if key.Matches(msg, cmdDef.KeyBinding) {
@@ -128,6 +133,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 
 	switch msg := msg.(type) {
+	case chat.SendMsg:
+		a.showCompletionDialog = false
+		cmd := a.sendMessage(msg.Text, msg.Attachments)
+		if cmd != nil {
+			return a, cmd
+		}
+	case dialog.CompletionDialogCloseMsg:
+		a.showCompletionDialog = false
 	case commands.ExecuteCommandMsg:
 		switch msg.Name {
 		case "quit":
@@ -135,7 +148,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "new":
 			a.app.Session = &client.SessionInfo{}
 			a.app.Messages = []client.MessageInfo{}
-			cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
+			cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
 		case "sessions":
 			sessionDialog := dialog.NewSessionDialog(a.app)
 			a.modal = sessionDialog
@@ -173,28 +186,23 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			BackgroundIsDark: msg.IsDark(),
 		}
 
-	case cursor.BlinkMsg:
-		return a.updateAllPages(msg)
-
-	case spinner.TickMsg:
-		return a.updateAllPages(msg)
-
 	case client.EventSessionUpdated:
 		if msg.Properties.Info.Id == a.app.Session.Id {
 			a.app.Session = &msg.Properties.Info
-			return a.updateAllPages(state.StateUpdatedMsg{State: nil})
 		}
 
 	case client.EventMessageUpdated:
 		if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
+			exists := false
 			for i, m := range a.app.Messages {
 				if m.Id == msg.Properties.Info.Id {
 					a.app.Messages[i] = msg.Properties.Info
-					return a.updateAllPages(state.StateUpdatedMsg{State: nil})
+					exists = true
 				}
 			}
-			a.app.Messages = append(a.app.Messages, msg.Properties.Info)
-			return a.updateAllPages(state.StateUpdatedMsg{State: nil})
+			if !exists {
+				a.app.Messages = append(a.app.Messages, msg.Properties.Info)
+			}
 		}
 
 	case tea.WindowSizeMsg:
@@ -212,18 +220,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			},
 		}
 
+		// Update status
 		s, cmd := a.status.Update(msg)
-		a.status = s.(core.StatusComponent)
+		a.status = s.(status.StatusComponent)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 
-		updated, cmd := a.pages[a.currentPage].Update(msg)
-		a.pages[a.currentPage] = updated.(layout.ModelWithView)
+		// Update chat layout
+		cmd = a.layout.SetSize(msg.Width, msg.Height)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 
+		// Update modal if present
 		if a.modal != nil {
 			s, cmd := a.modal.Update(msg)
 			a.modal = s.(layout.Modal)
@@ -234,35 +244,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		return a, tea.Batch(cmds...)
 
-	case page.PageChangeMsg:
-		return a, a.moveToPage(msg.ID)
-
-	case state.SessionSelectedMsg:
+	case app.SessionSelectedMsg:
 		a.app.Session = msg
 		a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
-		return a.updateAllPages(msg)
 
-	case state.ModelSelectedMsg:
+	case app.ModelSelectedMsg:
 		a.app.Provider = &msg.Provider
 		a.app.Model = &msg.Model
 		a.app.Config.Provider = msg.Provider.Id
 		a.app.Config.Model = msg.Model.Id
 		a.app.SaveConfig()
-		return a.updateAllPages(msg)
 
-	case dialog.ThemeChangedMsg:
+	case dialog.ThemeSelectedMsg:
 		a.app.Config.Theme = msg.ThemeName
 		a.app.SaveConfig()
 
-		updated, cmd := a.pages[a.currentPage].Update(msg)
-		a.pages[a.currentPage] = updated.(layout.ModelWithView)
+		// Update layout
+		u, cmd := a.layout.Update(msg)
+		a.layout = u.(layout.FlexLayout)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 
+		// Update status
 		s, cmd := a.status.Update(msg)
 		cmds = append(cmds, cmd)
-		a.status = s.(core.StatusComponent)
+		a.status = s.(status.StatusComponent)
 
 		t := theme.CurrentTheme()
 		cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
@@ -272,13 +279,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		// give the editor a chance to clear input
 		case "ctrl+c":
-			updated, cmd := a.pages[a.currentPage].Update(msg)
-			a.pages[a.currentPage] = updated.(layout.ModelWithView)
+			_, cmd := a.editorContainer.Update(msg)
 			if cmd != nil {
 				return a, cmd
 			}
 		}
 
+		// Handle chat-specific keys
+		switch {
+		case key.Matches(msg, keyMap.ShowCompletionDialog):
+			a.showCompletionDialog = true
+			// Continue sending keys to layout->chat
+		case key.Matches(msg, keyMap.Cancel):
+			if a.app.Session.Id != "" {
+				// Cancel the current session's generation process
+				// This allows users to interrupt long-running operations
+				a.app.Cancel(context.Background(), a.app.Session.Id)
+				return a, nil
+			}
+		case key.Matches(msg, keyMap.ToggleTools):
+			return a, util.CmdHandler(chat.ToggleToolMessagesMsg{})
+		}
+
 		// First, check for modal triggers from the command registry
 		if a.modal == nil {
 			for _, cmdDef := range a.app.Commands {
@@ -291,40 +313,64 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
+	if a.showCompletionDialog {
+		currentInput := a.editor.Value()
+		provider := a.completionManager.GetProvider(currentInput)
+		a.completionDialog.SetProvider(provider)
+
+		context, contextCmd := a.completionDialog.Update(msg)
+		a.completionDialog = context.(dialog.CompletionDialog)
+		cmds = append(cmds, contextCmd)
+
+		// Doesn't forward event if enter key is pressed
+		if keyMsg, ok := msg.(tea.KeyMsg); ok {
+			if keyMsg.String() == "enter" {
+				return a, tea.Batch(cmds...)
+			}
+		}
+	}
+
 	// update status bar
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
-	a.status = s.(core.StatusComponent)
+	a.status = s.(status.StatusComponent)
 
-	// update current page
-	updated, cmd := a.pages[a.currentPage].Update(msg)
-	a.pages[a.currentPage] = updated.(layout.ModelWithView)
+	// update chat layout
+	u, cmd := a.layout.Update(msg)
+	a.layout = u.(layout.FlexLayout)
 	cmds = append(cmds, cmd)
 	return a, tea.Batch(cmds...)
 }
 
-func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+func (a *appModel) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
 	var cmds []tea.Cmd
-	if _, ok := a.loadedPages[pageID]; !ok {
-		cmd := a.pages[pageID].Init()
-		cmds = append(cmds, cmd)
-		a.loadedPages[pageID] = true
-	}
-	a.previousPage = a.currentPage
-	a.currentPage = pageID
-	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
-		cmd := sizable.SetSize(a.width, a.height)
-		cmds = append(cmds, cmd)
-	}
-
+	cmd := a.app.SendChatMessage(context.Background(), text, attachments)
+	cmds = append(cmds, cmd)
 	return tea.Batch(cmds...)
 }
 
 func (a appModel) View() string {
+	layoutView := a.layout.View()
+
+	if a.showCompletionDialog {
+		editorWidth, _ := a.editorContainer.GetSize()
+		editorX, editorY := a.editorContainer.GetPosition()
+
+		a.completionDialog.SetWidth(editorWidth)
+		overlay := a.completionDialog.View()
+
+		layoutView = layout.PlaceOverlay(
+			editorX,
+			editorY-lipgloss.Height(overlay)+2,
+			overlay,
+			layoutView,
+		)
+	}
+
 	components := []string{
-		a.pages[a.currentPage].View(),
+		layoutView,
+		a.status.View(),
 	}
-	components = append(components, a.status.View())
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
 
 	if a.modal != nil {
@@ -335,15 +381,37 @@ func (a appModel) View() string {
 }
 
 func NewModel(app *app.App) tea.Model {
-	startPage := page.ChatPage
+	completionManager := completions.NewCompletionManager(app)
+	initialProvider := completionManager.GetProvider("")
+	completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
+
+	messagesContainer := layout.NewContainer(
+		chat.NewMessagesComponent(app),
+	)
+	editor := chat.NewEditorComponent(app)
+	editorContainer := layout.NewContainer(
+		editor,
+		layout.WithMaxWidth(layout.Current.Container.Width),
+		layout.WithAlignCenter(),
+	)
+
 	model := &appModel{
-		currentPage: startPage,
-		loadedPages: make(map[page.PageID]bool),
-		status:      core.NewStatusCmp(app),
-		app:         app,
-		pages: map[page.PageID]layout.ModelWithView{
-			page.ChatPage: page.NewChatPage(app),
-		},
+		status:               status.NewStatusCmp(app),
+		app:                  app,
+		editorContainer:      editorContainer,
+		editor:               editor,
+		messagesContainer:    messagesContainer,
+		completionDialog:     completionDialog,
+		completionManager:    completionManager,
+		showCompletionDialog: false,
+		layout: layout.NewFlexLayout(
+			layout.WithPanes(messagesContainer, editorContainer),
+			layout.WithDirection(layout.FlexDirectionVertical),
+			layout.WithPaneSizes(
+				layout.FlexPaneSizeGrow,
+				layout.FlexPaneSizeFixed(6),
+			),
+		),
 	}
 
 	return model