chore: rewrite the chat page

Kujtim Hoxha created

chore: small fixes

chore: small fixes

Change summary

internal/tui/components/chat/chat.go            |  30 
internal/tui/components/chat/editor/editor.go   |  26 
internal/tui/components/chat/header/header.go   |  17 
internal/tui/components/chat/sidebar/sidebar.go |  63 +
internal/tui/components/chat/splash/splash.go   |  21 
internal/tui/components/core/layout/split.go    |   5 
internal/tui/components/logo/logo.go            |   2 
internal/tui/page/chat/chat.go                  | 644 +++++++++++-------
internal/tui/tui.go                             |   2 
9 files changed, 486 insertions(+), 324 deletions(-)

Detailed changes

internal/tui/components/chat/chat.go 🔗

@@ -8,14 +8,15 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type SendMsg struct {
@@ -37,6 +38,9 @@ type MessageListCmp interface {
 	util.Model
 	layout.Sizeable
 	layout.Focusable
+	layout.Help
+
+	SetSession(session.Session) tea.Cmd
 }
 
 // messageListCmp implements MessageListCmp, providing a virtualized list
@@ -53,9 +57,9 @@ type messageListCmp struct {
 	defaultListKeyMap   list.KeyMap
 }
 
-// NewMessagesListCmp creates a new message list component with custom keybindings
+// New creates a new message list component with custom keybindings
 // and reverse ordering (newest messages at bottom).
-func NewMessagesListCmp(app *app.App) MessageListCmp {
+func New(app *app.App) MessageListCmp {
 	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
 		list.WithGapSize(1),
@@ -70,13 +74,14 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
 	}
 }
 
-// Init initializes the component (no initialization needed).
+// Init initializes the component.
 func (m *messageListCmp) Init() tea.Cmd {
 	return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
 }
 
 // Update handles incoming messages and updates the component state.
 func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	logging.Info("messageListCmp.Update", "msg", msg)
 	switch msg := msg.(type) {
 	case SessionSelectedMsg:
 		if msg.ID != m.session.ID {
@@ -102,11 +107,15 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View renders the message list or an initial screen if empty.
 func (m *messageListCmp) View() tea.View {
+	t := styles.CurrentTheme()
 	return tea.NewView(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			m.listCmp.View().String(),
-		),
+		t.S().Base.
+			Padding(1).
+			Width(m.width).
+			Height(m.height).
+			Render(
+				m.listCmp.View().String(),
+			),
 	)
 }
 
@@ -371,6 +380,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 
 // SetSession loads and displays messages for a new session.
 func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
+	logging.Info("messageListCmp.SetSession", "sessionID", session.ID)
 	if m.session.ID == session.ID {
 		return nil
 	}
@@ -489,8 +499,8 @@ func (m *messageListCmp) GetSize() (int, int) {
 // SetSize updates the component dimensions and propagates to the list component.
 func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
-	m.height = height - 1
-	return m.listCmp.SetSize(width, height-1)
+	m.height = height
+	return m.listCmp.SetSize(width-2, height-2) // for padding
 }
 
 // Blur implements MessageListCmp.

internal/tui/components/chat/editor/editor.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -26,6 +27,16 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
+type Editor interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+	layout.Help
+	layout.Positional
+
+	SetSession(session session.Session) tea.Cmd
+}
+
 type FileCompletionItem struct {
 	Path string // The file path
 }
@@ -145,11 +156,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
-	case chat.SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			m.session = msg
-		}
-		return m, nil
 	case filepicker.FilePickedMsg:
 		if len(m.attachments) >= maxAttachments {
 			return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
@@ -372,7 +378,14 @@ func (c *editorCmp) Bindings() []key.Binding {
 	return c.keyMap.KeyBindings()
 }
 
-func NewEditorCmp(app *app.App) util.Model {
+// TODO: most likely we do not need to have the session here
+// we need to move some functionality to the page level
+func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
+	c.session = session
+	return nil
+}
+
+func New(app *app.App) Editor {
 	t := styles.CurrentTheme()
 	ta := textarea.New()
 	ta.SetStyles(t.S().TextArea)
@@ -393,6 +406,7 @@ func NewEditorCmp(app *app.App) util.Model {
 	ta.Focus()
 
 	return &editorCmp{
+		// TODO: remove the app instance from here
 		app:      app,
 		textarea: ta,
 		keyMap:   DefaultEditorKeyMap(),

internal/tui/components/chat/header/header.go 🔗

@@ -11,7 +11,6 @@ import (
 	"github.com/charmbracelet/crush/internal/lsp/protocol"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -19,7 +18,8 @@ import (
 
 type Header interface {
 	util.Model
-	SetSession(session session.Session)
+	SetSession(session session.Session) tea.Cmd
+	SetWidth(width int) tea.Cmd
 	SetDetailsOpen(open bool)
 }
 
@@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd {
 
 func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.width = msg.Width - 2
-	case chat.SessionSelectedMsg:
-		p.session = msg
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {
 			if p.session.ID == msg.Payload.ID {
@@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) {
 }
 
 // SetSession implements Header.
-func (h *header) SetSession(session session.Session) {
+func (h *header) SetSession(session session.Session) tea.Cmd {
 	h.session = session
+	return nil
+}
+
+// SetWidth implements Header.
+func (h *header) SetWidth(width int) tea.Cmd {
+	h.width = width
+	return nil
 }

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -13,7 +13,6 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
-
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/lsp/protocol"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -29,10 +28,6 @@ import (
 	"github.com/charmbracelet/x/ansi"
 )
 
-const (
-	logoBreakpoint = 65
-)
-
 type FileHistory struct {
 	initialVersion history.File
 	latestVersion  history.File
@@ -52,6 +47,7 @@ type Sidebar interface {
 	util.Model
 	layout.Sizeable
 	SetSession(session session.Session) tea.Cmd
+	SetCompactMode(bool)
 }
 
 type sidebarCmp struct {
@@ -66,7 +62,7 @@ type sidebarCmp struct {
 	files sync.Map
 }
 
-func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
+func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
 	return &sidebarCmp{
 		lspClients:  lspClients,
 		history:     history,
@@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
 }
 
 func (m *sidebarCmp) Init() tea.Cmd {
-	m.logo = m.logoBlock(false)
-	m.cwd = cwd()
 	return nil
 }
 
 func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case chat.SessionSelectedMsg:
-		return m, m.SetSession(msg)
 	case SessionFilesMsg:
 		m.files = sync.Map{}
 		for _, file := range msg.Files {
@@ -137,8 +129,19 @@ func (m *sidebarCmp) View() tea.View {
 		m.mcpBlock(),
 	)
 
+	// TODO: CHECK out why we need to set the background here weird issue
+	style := t.S().Base.
+		Background(t.BgBase).
+		Width(m.width).
+		Height(m.height).
+		Padding(1)
+	if m.compactMode {
+		style = style.PaddingTop(0)
+	}
 	return tea.NewView(
-		lipgloss.JoinVertical(lipgloss.Left, parts...),
+		style.Render(
+			lipgloss.JoinVertical(lipgloss.Left, parts...),
+		),
 	)
 }
 
@@ -232,12 +235,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 }
 
 func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
-	if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
-		m.logo = m.logoBlock(true)
-	} else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
-		m.logo = m.logoBlock(false)
-	}
-
+	m.logo = m.logoBlock()
+	m.cwd = cwd()
 	m.width = width
 	m.height = height
 	return nil
@@ -247,9 +246,9 @@ func (m *sidebarCmp) GetSize() (int, int) {
 	return m.width, m.height
 }
 
-func (m *sidebarCmp) logoBlock(compact bool) string {
+func (m *sidebarCmp) logoBlock() string {
 	t := styles.CurrentTheme()
-	return logo.Render(version.Version, compact, logo.Opts{
+	return logo.Render(version.Version, true, logo.Opts{
 		FieldColor:   t.Primary,
 		TitleColorA:  t.Secondary,
 		TitleColorB:  t.Primary,
@@ -258,12 +257,15 @@ func (m *sidebarCmp) logoBlock(compact bool) string {
 	})
 }
 
+func (m *sidebarCmp) getMaxWidth() int {
+	return min(m.width-2, 58) // -2 for padding
+}
+
 func (m *sidebarCmp) filesBlock() string {
-	maxWidth := min(m.width, 58)
 	t := styles.CurrentTheme()
 
 	section := t.S().Subtle.Render(
-		core.Section("Modified Files", maxWidth),
+		core.Section("Modified Files", m.getMaxWidth()),
 	)
 
 	files := make([]SessionFile, 0)
@@ -304,7 +306,7 @@ func (m *sidebarCmp) filesBlock() string {
 		filePath := file.FilePath
 		filePath = strings.TrimPrefix(filePath, cwd)
 		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
-		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
+		filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
 		fileList = append(fileList,
 			core.Status(
 				core.StatusOpts{
@@ -313,7 +315,7 @@ func (m *sidebarCmp) filesBlock() string {
 					Title:        filePath,
 					ExtraContent: extraContent,
 				},
-				m.width,
+				m.getMaxWidth(),
 			),
 		)
 	}
@@ -325,11 +327,10 @@ func (m *sidebarCmp) filesBlock() string {
 }
 
 func (m *sidebarCmp) lspBlock() string {
-	maxWidth := min(m.width, 58)
 	t := styles.CurrentTheme()
 
 	section := t.S().Subtle.Render(
-		core.Section("LSPs", maxWidth),
+		core.Section("LSPs", m.getMaxWidth()),
 	)
 
 	lspList := []string{section, ""}
@@ -387,7 +388,7 @@ func (m *sidebarCmp) lspBlock() string {
 					Description:  l.Command,
 					ExtraContent: strings.Join(errs, " "),
 				},
-				m.width,
+				m.getMaxWidth(),
 			),
 		)
 	}
@@ -399,11 +400,10 @@ func (m *sidebarCmp) lspBlock() string {
 }
 
 func (m *sidebarCmp) mcpBlock() string {
-	maxWidth := min(m.width, 58)
 	t := styles.CurrentTheme()
 
 	section := t.S().Subtle.Render(
-		core.Section("MCPs", maxWidth),
+		core.Section("MCPs", m.getMaxWidth()),
 	)
 
 	mcpList := []string{section, ""}
@@ -427,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string {
 					Title:       n,
 					Description: l.Command,
 				},
-				m.width,
+				m.getMaxWidth(),
 			),
 		)
 	}
@@ -510,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
 	return m.loadSessionFiles
 }
 
+// SetCompactMode sets the compact mode for the sidebar.
+func (m *sidebarCmp) SetCompactMode(compact bool) {
+	m.compactMode = compact
+}
+
 func cwd() string {
 	cwd := config.Get().WorkingDir()
 	t := styles.CurrentTheme()

internal/tui/components/chat/splash/splash.go 🔗

@@ -17,6 +17,11 @@ type Splash interface {
 	layout.Help
 }
 
+const (
+	SplashScreenPaddingX = 2 // Padding X for the splash screen
+	SplashScreenPaddingY = 1 // Padding Y for the splash screen
+)
+
 type splashCmp struct {
 	width, height int
 	keyMap        KeyMap
@@ -61,8 +66,20 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements SplashPage.
 func (s *splashCmp) View() tea.View {
+	t := styles.CurrentTheme()
 	content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
-	return tea.NewView(content)
+	return tea.NewView(
+		t.S().Base.
+			Width(s.width).
+			Height(s.height).
+			PaddingTop(SplashScreenPaddingY).
+			PaddingLeft(SplashScreenPaddingX).
+			PaddingRight(SplashScreenPaddingX).
+			PaddingBottom(SplashScreenPaddingY).
+			Render(
+				content,
+			),
+	)
 }
 
 func (s *splashCmp) logoBlock() string {
@@ -74,7 +91,7 @@ func (s *splashCmp) logoBlock() string {
 		TitleColorB:  t.Primary,
 		CharmColor:   t.Secondary,
 		VersionColor: t.Primary,
-		Width:        s.width - padding,
+		Width:        s.width - (SplashScreenPaddingX * 2),
 	})
 }
 

internal/tui/components/core/layout/split.go 🔗

@@ -1,11 +1,8 @@
 package layout
 
 import (
-	"log/slog"
-
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -156,8 +153,6 @@ func (s *splitPaneLayout) View() tea.View {
 func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	s.width = width
 	s.height = height
-	slog.Info("Setting split pane size", "width", width, "height", height)
-
 	var topHeight, bottomHeight int
 	var cmds []tea.Cmd
 	if s.bottomPanel != nil {

internal/tui/components/logo/logo.go 🔗

@@ -85,7 +85,7 @@ func Render(version string, compact bool, o Opts) string {
 	}
 
 	// Right field.
-	rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
+	rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
 	const stepDownAt = 0
 	rightField := new(strings.Builder)
 	for i := range fieldHeight {

internal/tui/page/chat/chat.go 🔗

@@ -2,21 +2,27 @@ package chat
 
 import (
 	"context"
-	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/page"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -26,8 +32,6 @@ import (
 
 var ChatPageID page.PageID = "chat"
 
-const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
-
 type (
 	OpenFilePickerMsg struct{}
 	ChatFocusedMsg    struct {
@@ -36,104 +40,171 @@ type (
 	CancelTimerExpiredMsg struct{}
 )
 
+type ChatState string
+
+const (
+	ChatStateOnboarding  ChatState = "onboarding"
+	ChatStateInitProject ChatState = "init_project"
+	ChatStateNewMessage  ChatState = "new_message"
+	ChatStateInSession   ChatState = "in_session"
+)
+
+type PanelType string
+
+const (
+	PanelTypeChat   PanelType = "chat"
+	PanelTypeEditor PanelType = "editor"
+	PanelTypeSplash PanelType = "splash"
+)
+
+const (
+	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
+	EditorHeight          = 5   // Height of the editor input area including padding
+	SideBarWidth          = 31  // Width of the sidebar
+	SideBarDetailsPadding = 1   // Padding for the sidebar details section
+	HeaderHeight          = 1   // Height of the header
+)
+
 type ChatPage interface {
 	util.Model
 	layout.Help
 }
 
-type chatPage struct {
-	wWidth, wHeight int // Window dimensions
-	app             *app.App
-
-	layout layout.SplitPaneLayout
-
-	session session.Session
+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
+func cancelTimerCmd() tea.Cmd {
+	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
+		return CancelTimerExpiredMsg{}
+	})
+}
 
-	keyMap KeyMap
+type chatPage struct {
+	width, height               int
+	detailsWidth, detailsHeight int
+	app                         *app.App
+	state                       ChatState
+	session                     session.Session
+	keyMap                      KeyMap
+	focusedPane                 PanelType
+	// Compact mode
+	compact        bool
+	header         header.Header
+	showingDetails bool
+
+	sidebar   sidebar.Sidebar
+	chat      chat.MessageListCmp
+	editor    editor.Editor
+	splash    splash.Splash
+	canceling bool
+
+	// This will force the compact mode even in big screens
+	// usually triggered by the user command
+	// this will also be set when the user config is set to compact mode
+	forceCompact bool
+}
 
-	chatFocused bool
+func New(app *app.App) ChatPage {
+	return &chatPage{
+		app:   app,
+		state: ChatStateOnboarding,
 
-	compactMode      bool
-	forceCompactMode bool // Force compact mode regardless of window size
-	showDetails      bool // Show details in the header
-	header           header.Header
-	compactSidebar   layout.Container
+		keyMap: DefaultKeyMap(),
 
-	cancelPending bool // True if ESC was pressed once and waiting for second press
+		header:  header.New(app.LSPClients),
+		sidebar: sidebar.New(app.History, app.LSPClients, false),
+		chat:    chat.New(app),
+		editor:  editor.New(app),
+		splash:  splash.New(),
+	}
 }
 
 func (p *chatPage) Init() tea.Cmd {
+	cfg := config.Get()
+	if cfg.IsReady() {
+		if b, _ := config.ProjectNeedsInitialization(); b {
+			p.state = ChatStateInitProject
+		} else {
+			p.state = ChatStateNewMessage
+			p.focusedPane = PanelTypeEditor
+		}
+	}
+
+	compact := cfg.Options.TUI.CompactMode
+	p.compact = compact
+	p.forceCompact = compact
+	p.sidebar.SetCompactMode(p.compact)
 	return tea.Batch(
-		p.layout.Init(),
-		p.compactSidebar.Init(),
-		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
+		p.header.Init(),
+		p.sidebar.Init(),
+		p.chat.Init(),
+		p.editor.Init(),
+		p.splash.Init(),
 	)
 }
 
-// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
-func (p *chatPage) cancelTimerCmd() tea.Cmd {
-	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
-		return CancelTimerExpiredMsg{}
-	})
-}
-
 func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		return p, p.SetSize(msg.Width, msg.Height)
 	case CancelTimerExpiredMsg:
-		p.cancelPending = false
+		p.canceling = false
 		return p, nil
-	case tea.WindowSizeMsg:
-		h, cmd := p.header.Update(msg)
-		cmds = append(cmds, cmd)
-		p.header = h.(header.Header)
-		cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
-		// the mode is only relevant when there is a  session
-		if p.session.ID != "" {
-			// Only auto-switch to compact mode if not forced
-			if !p.forceCompactMode {
-				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
-					p.wWidth = msg.Width
-					p.wHeight = msg.Height
-					cmds = append(cmds, p.setCompactMode(true))
-					return p, tea.Batch(cmds...)
-				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
-					p.wWidth = msg.Width
-					p.wHeight = msg.Height
-					return p, p.setCompactMode(false)
-				}
-			}
-		}
-		p.wWidth = msg.Width
-		p.wHeight = msg.Height
-		layoutHeight := msg.Height
-		if p.compactMode {
-			// make space for the header
-			layoutHeight -= 1
+	case chat.SendMsg:
+		return p, p.sendMessage(msg.Text, msg.Attachments)
+	case chat.SessionSelectedMsg:
+		return p, p.setSession(msg)
+	case commands.ToggleCompactModeMsg:
+		p.forceCompact = !p.forceCompact
+		if p.forceCompact {
+			p.setCompactMode(true)
+		} else if p.width >= CompactModeBreakpoint {
+			p.setCompactMode(false)
 		}
-		cmd = p.layout.SetSize(msg.Width, layoutHeight)
+		return p, p.SetSize(p.width, p.height)
+	case pubsub.Event[session.Session]:
+		// this needs to go to header/sidebar
+		u, cmd := p.header.Update(msg)
+		p.header = u.(header.Header)
+		cmds = append(cmds, cmd)
+		u, cmd = p.sidebar.Update(msg)
+		p.sidebar = u.(sidebar.Sidebar)
+		cmds = append(cmds, cmd)
+		return p, tea.Batch(cmds...)
+	case chat.SessionClearedMsg:
+		u, cmd := p.header.Update(msg)
+		p.header = u.(header.Header)
+		cmds = append(cmds, cmd)
+		u, cmd = p.sidebar.Update(msg)
+		p.sidebar = u.(sidebar.Sidebar)
+		cmds = append(cmds, cmd)
+		u, cmd = p.chat.Update(msg)
+		p.chat = u.(chat.MessageListCmp)
+		cmds = append(cmds, cmd)
+		return p, tea.Batch(cmds...)
+	case filepicker.FilePickedMsg,
+		completions.CompletionsClosedMsg,
+		completions.SelectCompletionMsg:
+		u, cmd := p.editor.Update(msg)
+		p.editor = u.(editor.Editor)
+		cmds = append(cmds, cmd)
+		return p, tea.Batch(cmds...)
+
+	case pubsub.Event[message.Message],
+		anim.StepMsg,
+		spinner.TickMsg:
+		// this needs to go to chat
+		u, cmd := p.chat.Update(msg)
+		p.chat = u.(chat.MessageListCmp)
+		cmds = append(cmds, cmd)
+		return p, tea.Batch(cmds...)
+
+	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
+		// this needs to go to sidebar
+		u, cmd := p.sidebar.Update(msg)
+		p.sidebar = u.(sidebar.Sidebar)
 		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 
-	case chat.SendMsg:
-		cmd := p.sendMessage(msg.Text, msg.Attachments)
-		if cmd != nil {
-			return p, cmd
-		}
-	case commands.ToggleCompactModeMsg:
-		// Only allow toggling if window width is larger than compact breakpoint
-		if p.wWidth > CompactModeBreakpoint {
-			p.forceCompactMode = !p.forceCompactMode
-			// If force compact mode is enabled, switch to compact mode
-			// If force compact mode is disabled, switch based on window size
-			if p.forceCompactMode {
-				return p, p.setCompactMode(true)
-			} else {
-				// Return to auto mode based on window size
-				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
-				return p, p.setCompactMode(shouldBeCompact)
-			}
-		}
 	case commands.CommandRunCustomMsg:
 		// Check if the agent is busy before executing custom commands
 		if p.app.CoderAgent.IsBusy() {
@@ -145,30 +216,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			return p, cmd
 		}
-	case chat.SessionSelectedMsg:
-		if p.session.ID == "" {
-			cmd := p.setMessages()
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-		needsModeChange := p.session.ID == ""
-		p.session = msg
-		p.header.SetSession(msg)
-		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
-			cmds = append(cmds, p.setCompactMode(true))
-		}
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
-			p.session = session.Session{}
-			return p, tea.Batch(
-				p.clearMessages(),
-				util.CmdHandler(chat.SessionClearedMsg{}),
-				p.setCompactMode(false),
-				p.layout.FocusPanel(layout.BottomPanel),
-				util.CmdHandler(ChatFocusedMsg{Focused: false}),
-			)
+			return p, p.newSession()
 		case key.Matches(msg, p.keyMap.AddAttachment):
 			agentCfg := config.Get().Agents["coder"]
 			model := config.Get().GetModelByType(agentCfg.Model)
@@ -178,167 +229,264 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 			}
 		case key.Matches(msg, p.keyMap.Tab):
-			if p.session.ID == "" {
-				return p, nil
-			}
-			p.chatFocused = !p.chatFocused
-			if p.chatFocused {
-				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
-				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
-			} else {
-				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
-				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
-			}
-			return p, tea.Batch(cmds...)
+			p.changeFocus()
+			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
-			if p.session.ID != "" {
-				if p.cancelPending {
-					// Second ESC press - actually cancel the session
-					p.cancelPending = false
-					p.app.CoderAgent.Cancel(p.session.ID)
-					return p, nil
-				} else {
-					// First ESC press - start the timer
-					p.cancelPending = true
-					return p, p.cancelTimerCmd()
-				}
-			}
+			return p, p.cancel()
 		case key.Matches(msg, p.keyMap.Details):
-			if p.session.ID == "" || !p.compactMode {
-				return p, nil // No session to show details for
-			}
-			p.showDetails = !p.showDetails
-			p.header.SetDetailsOpen(p.showDetails)
-			if p.showDetails {
-				return p, tea.Batch()
-			}
-
+			p.showDetails()
 			return p, nil
 		}
+
+		// Send the key press to the focused pane
+		switch p.focusedPane {
+		case PanelTypeChat:
+			u, cmd := p.chat.Update(msg)
+			p.chat = u.(chat.MessageListCmp)
+			cmds = append(cmds, cmd)
+		case PanelTypeEditor:
+			u, cmd := p.editor.Update(msg)
+			p.editor = u.(editor.Editor)
+			cmds = append(cmds, cmd)
+		}
 	}
-	u, cmd := p.layout.Update(msg)
-	cmds = append(cmds, cmd)
-	p.layout = u.(layout.SplitPaneLayout)
-	h, cmd := p.header.Update(msg)
-	p.header = h.(header.Header)
-	cmds = append(cmds, cmd)
-	s, cmd := p.compactSidebar.Update(msg)
-	p.compactSidebar = s.(layout.Container)
-	cmds = append(cmds, cmd)
 	return p, tea.Batch(cmds...)
 }
 
-func (p *chatPage) setMessages() tea.Cmd {
-	messagesContainer := layout.NewContainer(
-		chat.NewMessagesListCmp(p.app),
-		layout.WithPadding(1, 1, 0, 1),
-	)
-	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
-}
+func (p *chatPage) View() tea.View {
+	var chatView tea.View
+	t := styles.CurrentTheme()
+	switch p.state {
+	case ChatStateOnboarding, ChatStateInitProject:
+		chatView = tea.NewView(
+			t.S().Base.Render(
+				p.splash.View().String(),
+			),
+		)
+	case ChatStateNewMessage:
+		editorView := p.editor.View()
+		chatView = tea.NewView(
+			lipgloss.JoinVertical(
+				lipgloss.Left,
+				t.S().Base.Render(
+					p.splash.View().String(),
+				),
+				editorView.String(),
+			),
+		)
+		chatView.SetCursor(editorView.Cursor())
+	case ChatStateInSession:
+		messagesView := p.chat.View()
+		editorView := p.editor.View()
+		if p.compact {
+			headerView := p.header.View()
+			chatView = tea.NewView(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					headerView.String(),
+					messagesView.String(),
+					editorView.String(),
+				),
+			)
+			chatView.SetCursor(editorView.Cursor())
+		} else {
+			sidebarView := p.sidebar.View()
+			messages := lipgloss.JoinHorizontal(
+				lipgloss.Left,
+				messagesView.String(),
+				sidebarView.String(),
+			)
+			chatView = tea.NewView(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					messages,
+					p.editor.View().String(),
+				),
+			)
+			chatView.SetCursor(editorView.Cursor())
+		}
+	default:
+		chatView = tea.NewView("Unknown chat state")
+	}
 
-func (p *chatPage) setSidebar() tea.Cmd {
-	sidebarContainer := sidebarCmp(p.app, false, p.session)
-	sidebarContainer.Init()
-	return p.layout.SetRightPanel(sidebarContainer)
-}
+	layers := []*lipgloss.Layer{
+		lipgloss.NewLayer(chatView.String()).X(0).Y(0),
+	}
 
-func (p *chatPage) clearMessages() tea.Cmd {
-	return p.layout.ClearLeftPanel()
+	if p.showingDetails {
+		style := t.S().Base.
+			Width(p.detailsWidth).
+			Border(lipgloss.RoundedBorder()).
+			BorderForeground(t.BorderFocus)
+		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
+		details := style.Render(
+			lipgloss.JoinVertical(
+				lipgloss.Left,
+				p.sidebar.View().String(),
+				version,
+			),
+		)
+		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
+	}
+	canvas := lipgloss.NewCanvas(
+		layers...,
+	)
+	view := tea.NewView(canvas.Render())
+	view.SetCursor(chatView.Cursor())
+	return view
 }
 
-func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
-	p.compactMode = compact
-	var cmds []tea.Cmd
+func (p *chatPage) setCompactMode(compact bool) {
+	if p.compact == compact {
+		return
+	}
+	p.compact = compact
 	if compact {
-		// add offset for the header
-		p.layout.SetOffset(0, 1)
-		// make space for the header
-		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
-		// remove the sidebar
-		cmds = append(cmds, p.layout.ClearRightPanel())
-		return tea.Batch(cmds...)
+		p.compact = true
+		p.sidebar.SetCompactMode(true)
 	} else {
-		// remove the offset for the header
-		p.layout.SetOffset(0, 0)
-		// restore the original size
-		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
-		// set the sidebar
-		cmds = append(cmds, p.setSidebar())
-		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
-		p.layout = l.(layout.SplitPaneLayout)
-		cmds = append(cmds, cmd)
+		p.compact = false
+		p.showingDetails = false
+		p.sidebar.SetCompactMode(false)
+	}
+}
 
-		return tea.Batch(cmds...)
+func (p *chatPage) handleCompactMode(newWidth int) {
+	if p.forceCompact {
+		return
+	}
+	if newWidth < CompactModeBreakpoint && !p.compact {
+		p.setCompactMode(true)
+	}
+	if newWidth >= CompactModeBreakpoint && p.compact {
+		p.setCompactMode(false)
 	}
 }
 
-func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+func (p *chatPage) SetSize(width, height int) tea.Cmd {
+	p.handleCompactMode(width)
+	p.width = width
+	p.height = height
 	var cmds []tea.Cmd
-	if p.session.ID == "" {
-		session, err := p.app.Sessions.Create(context.Background(), "New Session")
-		if err != nil {
-			return util.ReportError(err)
+	switch p.state {
+	case ChatStateOnboarding, ChatStateInitProject:
+		// here we should just have the splash screen
+		cmds = append(cmds, p.splash.SetSize(width, height))
+	case ChatStateNewMessage:
+		cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
+		cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
+		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
+	case ChatStateInSession:
+		if p.compact {
+			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
+			// In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
+			p.detailsWidth = width - 2                                                  // because of position
+			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
+			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
+			cmds = append(cmds, p.header.SetWidth(width-1))
+		} else {
+			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
+			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
+			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 		}
+		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
+	}
+	return tea.Batch(cmds...)
+}
 
-		p.session = session
-		cmd := p.setMessages()
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+func (p *chatPage) newSession() tea.Cmd {
+	if p.state != ChatStateInSession {
+		// Cannot start a new session if we are not in the session state
+		return nil
 	}
+	// blank session
+	p.session = session.Session{}
+	p.state = ChatStateNewMessage
+	p.focusedPane = PanelTypeEditor
+	p.canceling = false
+	// Reset the chat and editor components
+	return tea.Batch(
+		util.CmdHandler(chat.SessionClearedMsg{}),
+		p.SetSize(p.width, p.height),
+	)
+}
 
-	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
-	if err != nil {
-		return util.ReportError(err)
+func (p *chatPage) setSession(session session.Session) tea.Cmd {
+	if p.session.ID == session.ID {
+		return nil
 	}
-	return tea.Batch(cmds...)
+
+	var cmds []tea.Cmd
+	p.session = session
+	// We want to first resize the components
+	if p.state != ChatStateInSession {
+		p.state = ChatStateInSession
+		cmds = append(cmds, p.SetSize(p.width, p.height))
+	}
+	cmds = append(cmds, p.chat.SetSession(session))
+	cmds = append(cmds, p.sidebar.SetSession(session))
+	cmds = append(cmds, p.header.SetSession(session))
+	cmds = append(cmds, p.editor.SetSession(session))
+
+	return tea.Sequence(cmds...)
 }
 
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	return p.layout.SetSize(width, height)
+func (p *chatPage) changeFocus() {
+	if p.state != ChatStateInSession {
+		// Cannot change focus if we are not in the session state
+		return
+	}
+	switch p.focusedPane {
+	case PanelTypeChat:
+		p.focusedPane = PanelTypeEditor
+	case PanelTypeEditor:
+		p.focusedPane = PanelTypeChat
+	}
 }
 
-func (p *chatPage) GetSize() (int, int) {
-	return p.layout.GetSize()
+func (p *chatPage) cancel() tea.Cmd {
+	if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
+		// Cannot cancel if we are not in the session state
+		return nil
+	}
+
+	// second press of cancel key will actually cancel the session
+	if p.canceling {
+		p.canceling = false
+		p.app.CoderAgent.Cancel(p.session.ID)
+		return nil
+	}
+
+	p.canceling = true
+	return cancelTimerCmd()
 }
 
-func (p *chatPage) View() tea.View {
-	if !p.compactMode || p.session.ID == "" {
-		// If not in compact mode or there is no session, we don't show the header
-		return p.layout.View()
+func (p *chatPage) showDetails() {
+	if p.state != ChatStateInSession || !p.compact {
+		// Cannot show details if we are not in the session state or if we are not in compact mode
+		return
 	}
-	layoutView := p.layout.View()
-	chatView := strings.Join(
-		[]string{
-			p.header.View().String(),
-			layoutView.String(),
-		}, "\n",
-	)
-	layers := []*lipgloss.Layer{
-		lipgloss.NewLayer(chatView).X(0).Y(0),
+	p.showingDetails = !p.showingDetails
+	p.header.SetDetailsOpen(p.showingDetails)
+}
+
+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+	session := p.session
+	var cmds []tea.Cmd
+	if p.state != ChatStateInSession {
+		// branch new session
+		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return util.ReportError(err)
+		}
+		session = newSession
+		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 	}
-	if p.showDetails {
-		t := styles.CurrentTheme()
-		style := t.S().Base.
-			Border(lipgloss.RoundedBorder()).
-			BorderForeground(t.BorderFocus)
-		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
-		details := style.Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				p.compactSidebar.View().String(),
-				version,
-			),
-		)
-		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
+	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
+	if err != nil {
+		return util.ReportError(err)
 	}
-	canvas := lipgloss.NewCanvas(
-		layers...,
-	)
-	view := tea.NewView(canvas.Render())
-	view.SetCursor(layoutView.Cursor())
-	return view
+	return tea.Batch(cmds...)
 }
 
 func (p *chatPage) Bindings() []key.Binding {
@@ -348,7 +496,7 @@ func (p *chatPage) Bindings() []key.Binding {
 	}
 	if p.app.CoderAgent.IsBusy() {
 		cancelBinding := p.keyMap.Cancel
-		if p.cancelPending {
+		if p.canceling {
 			cancelBinding = key.NewBinding(
 				key.WithKeys("esc"),
 				key.WithHelp("esc", "press again to cancel"),
@@ -357,56 +505,26 @@ func (p *chatPage) Bindings() []key.Binding {
 		bindings = append([]key.Binding{cancelBinding}, bindings...)
 	}
 
-	if p.chatFocused {
+	switch p.focusedPane {
+	case PanelTypeChat:
 		bindings = append([]key.Binding{
 			key.NewBinding(
 				key.WithKeys("tab"),
 				key.WithHelp("tab", "focus editor"),
 			),
 		}, bindings...)
-	} else {
+		bindings = append(bindings, p.chat.Bindings()...)
+	case PanelTypeEditor:
 		bindings = append([]key.Binding{
 			key.NewBinding(
 				key.WithKeys("tab"),
 				key.WithHelp("tab", "focus chat"),
 			),
 		}, bindings...)
+		bindings = append(bindings, p.editor.Bindings()...)
+	case PanelTypeSplash:
+		bindings = append(bindings, p.splash.Bindings()...)
 	}
 
-	bindings = append(bindings, p.layout.Bindings()...)
 	return bindings
 }
-
-func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
-	padding := layout.WithPadding(1, 1, 1, 1)
-	if compact {
-		padding = layout.WithPadding(0, 1, 1, 1)
-	}
-	sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
-	if session.ID != "" {
-		sidebar.SetSession(session)
-	}
-
-	return layout.NewContainer(
-		sidebar,
-		padding,
-	)
-}
-
-func NewChatPage(app *app.App) ChatPage {
-	editorContainer := layout.NewContainer(
-		editor.NewEditorCmp(app),
-	)
-	return &chatPage{
-		app: app,
-		layout: layout.NewSplitPane(
-			layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
-			layout.WithBottomPanel(editorContainer),
-			layout.WithFixedBottomHeight(5),
-			layout.WithFixedRightWidth(31),
-		),
-		compactSidebar: sidebarCmp(app, true, session.Session{}),
-		keyMap:         DefaultKeyMap(),
-		header:         header.New(app.LSPClients),
-	}
-}

internal/tui/tui.go 🔗

@@ -419,7 +419,7 @@ func (a *appModel) View() tea.View {
 
 // New creates and initializes a new TUI application model.
 func New(app *app.App) tea.Model {
-	chatPage := chat.NewChatPage(app)
+	chatPage := chat.New(app)
 	keyMap := DefaultKeyMap()
 	keyMap.pageBindings = chatPage.Bindings()