wip theme + new UI

Kujtim Hoxha created

Change summary

.opencode.json                                  |   4 
cspell.json                                     |   2 
diff.diff                                       | 124 ++++++++++
go.mod                                          |   1 
go.sum                                          |   2 
internal/tui/components/chat/chat.go            |   2 
internal/tui/components/chat/editor/editor.go   |  64 +---
internal/tui/components/chat/sidebar/sidebar.go | 205 +++++++++++++++++
internal/tui/components/core/helpers.go         |  63 +++++
internal/tui/components/logo/logo.go            |   2 
internal/tui/layout/container.go                |   9 
internal/tui/layout/split.go                    |  50 +++
internal/tui/page/chat/chat.go                  | 173 ++++++++++++++
internal/tui/page/chat/keys.go                  |   1 
internal/tui/styles/crush.go                    |  44 +++
internal/tui/styles/theme.go                    | 229 +++++++++++++++++++
internal/tui/tui.go                             |  23 +
todos.md                                        |   8 
18 files changed, 938 insertions(+), 68 deletions(-)

Detailed changes

.opencode.json 🔗

@@ -1,11 +1,11 @@
 {
   "$schema": "./opencode-schema.json",
   "lsp": {
-    "gopls": {
+    "Go": {
       "command": "gopls"
     }
   },
   "tui": {
-    "theme": "opencode-dark"
+    "theme": "charm"
   }
 }

cspell.json 🔗

@@ -1 +1 @@
-{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
+{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps"],"version":"0.2","language":"en","flagWords":[]}

diff.diff 🔗

@@ -0,0 +1,124 @@
+diff --git a/.opencode.json b/.opencode.json
+index 75e357d..59be1e8 100644
+--- a/.opencode.json
++++ b/.opencode.json
+@@ -6,6 +6,6 @@
+     }
+   },
+   "tui": {
+-    "theme": "opencode-dark"
++    "theme": "charm"
+   }
+ }
+diff --git a/go.mod b/go.mod
+index 18ad042..940a8e8 100644
+--- a/go.mod
++++ b/go.mod
+@@ -36,6 +36,8 @@ require (
+ 	github.com/stretchr/testify v1.10.0
+ )
+ 
++require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 // indirect
++
+ require (
+ 	cloud.google.com/go v0.116.0 // indirect
+ 	cloud.google.com/go/auth v0.13.0 // indirect
+diff --git a/go.sum b/go.sum
+index f6e08b7..8f347ed 100644
+--- a/go.sum
++++ b/go.sum
+@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
+diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
+index 2ee0b04..52c4dae 100644
+--- a/internal/tui/components/chat/chat.go
++++ b/internal/tui/components/chat/chat.go
+@@ -95,7 +95,7 @@ func lspsConfigured() string {
+ func logoBlock() string {
+ 	t := theme.CurrentTheme()
+ 	return logo.Render(version.Version, true, logo.Opts{
+-		FieldColor:   t.Accent(),
++		FieldColor:   t.Secondary(),
+ 		TitleColorA:  t.Primary(),
+ 		TitleColorB:  t.Secondary(),
+ 		CharmColor:   t.Primary(),
+diff --git a/internal/tui/tui.go b/internal/tui/tui.go
+index 9e8a62a..3f07956 100644
+--- a/internal/tui/tui.go
++++ b/internal/tui/tui.go
+@@ -18,6 +18,7 @@ import (
+ 	"github.com/opencode-ai/opencode/internal/tui/util"
+ )
+ 
++// appModel represents the main application model that manages pages, dialogs, and UI state.
+ type appModel struct {
+ 	width, height int
+ 	keyMap        KeyMap
+@@ -35,6 +36,7 @@ type appModel struct {
+ 	completions completions.Completions
+ }
+ 
++// Init initializes the application model and returns initial commands.
+ func (a appModel) Init() tea.Cmd {
+ 	var cmds []tea.Cmd
+ 	cmd := a.pages[a.currentPage].Init()
+@@ -46,6 +48,7 @@ func (a appModel) Init() tea.Cmd {
+ 	return tea.Batch(cmds...)
+ }
+ 
++// Update handles incoming messages and updates the application state.
+ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ 	var cmds []tea.Cmd
+ 	var cmd tea.Cmd
+@@ -111,6 +114,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ 	return a, tea.Batch(cmds...)
+ }
+ 
++// handleWindowResize processes window resize events and updates all components.
+ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ 	var cmds []tea.Cmd
+ 	msg.Height -= 1 // Make space for the status bar
+@@ -134,6 +138,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ 	return tea.Batch(cmds...)
+ }
+ 
++// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
+ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ 	switch {
+ 	// completions
+@@ -182,11 +187,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ 	}
+ }
+ 
+-// RegisterCommand adds a command to the command dialog
+-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
+-// 	a.commands = append(a.commands, cmd)
+-// }
+-
++// moveToPage handles navigation between different pages in the application.
+ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ 	if a.app.CoderAgent.IsBusy() {
+ 		// For now we don't move to any page if the agent is busy
+@@ -209,6 +210,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ 	return tea.Batch(cmds...)
+ }
+ 
++// View renders the complete application interface including pages, dialogs, and overlays.
+ func (a *appModel) View() tea.View {
+ 	pageView := a.pages[a.currentPage].View()
+ 	components := []string{
+@@ -252,6 +254,7 @@ func (a *appModel) View() tea.View {
+ 	return view
+ }
+ 
++// New creates and initializes a new TUI application model.
+ func New(app *app.App) tea.Model {
+ 	startPage := page.ChatPage
+ 	model := &appModel{

go.mod 🔗

@@ -18,6 +18,7 @@ require (
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
 	github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa
+	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/go-logfmt/logfmt v0.6.0
 	github.com/google/uuid v1.6.0

go.sum 🔗

@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
 github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=

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

@@ -95,7 +95,7 @@ func lspsConfigured() string {
 func logoBlock() string {
 	t := theme.CurrentTheme()
 	return logo.Render(version.Version, true, logo.Opts{
-		FieldColor:   t.Accent(),
+		FieldColor:   t.Secondary(),
 		TitleColorA:  t.Primary(),
 		TitleColorB:  t.Secondary(),
 		CharmColor:   t.Primary(),

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

@@ -261,29 +261,25 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *editorCmp) View() tea.View {
-	t := theme.CurrentTheme()
-
-	// Style the prompt with theme colors
-	style := lipgloss.NewStyle().
-		Padding(0, 0, 0, 1).
-		Bold(true).
-		Foreground(t.Primary())
-
+	t := styles.CurrentTheme()
 	cursor := m.textarea.Cursor()
-	cursor.X = cursor.X + m.x + 2
-	cursor.Y = cursor.Y + m.y + 1
+	cursor.X = cursor.X + m.x + 1
+	cursor.Y = cursor.Y + m.y + 1 // adjust for padding
 	if len(m.attachments) == 0 {
-		view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
+		content := t.S().Base.Padding(1).Render(
+			m.textarea.View(),
+		)
+		view := tea.NewView(content)
 		view.SetCursor(cursor)
 		return view
 	}
-	m.textarea.SetHeight(m.height - 1)
-	view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
-		m.attachmentsContent(),
-		lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
+	content := t.S().Base.Padding(0, 1, 1, 1).Render(
+		lipgloss.JoinVertical(lipgloss.Top,
+			m.attachmentsContent(),
 			m.textarea.View(),
 		),
-	))
+	)
+	view := tea.NewView(content)
 	view.SetCursor(cursor)
 	return view
 }
@@ -291,8 +287,8 @@ func (m *editorCmp) View() tea.View {
 func (m *editorCmp) SetSize(width, height int) tea.Cmd {
 	m.width = width
 	m.height = height
-	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
-	m.textarea.SetHeight(height)
+	m.textarea.SetWidth(width - 2)   // adjust for padding
+	m.textarea.SetHeight(height - 2) // adjust for padding
 	return nil
 }
 
@@ -359,32 +355,18 @@ func (m *editorCmp) startCompletions() tea.Msg {
 }
 
 func CreateTextArea(existing *textarea.Model) textarea.Model {
-	t := theme.CurrentTheme()
-	bgColor := t.Background()
-	textColor := t.Text()
-	textMutedColor := t.TextMuted()
-
+	t := styles.CurrentTheme()
 	ta := textarea.New()
-	s := textarea.DefaultDarkStyles()
-	b := s.Blurred
-	b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	b.CursorLine = styles.BaseStyle().Background(bgColor)
-	b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
-	f := s.Focused
-	f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	f.CursorLine = styles.BaseStyle().Background(bgColor)
-	f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
-	s.Focused = f
-	s.Blurred = b
-	ta.SetStyles(s)
-
-	ta.Prompt = " "
+	ta.SetStyles(t.S().TextArea)
+	ta.SetPromptFunc(2, func(lineIndex int) string {
+		if lineIndex == 0 {
+			return "> "
+		}
+		return t.S().Muted.Render(": ")
+	})
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
+	ta.Placeholder = "Tell me more about this project..."
 	ta.SetVirtualCursor(false)
 
 	if existing != nil {

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

@@ -0,0 +1,205 @@
+package sidebar
+
+import (
+	"os"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/pubsub"
+	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat"
+	"github.com/opencode-ai/opencode/internal/tui/components/core"
+	"github.com/opencode-ai/opencode/internal/tui/components/logo"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+	"github.com/opencode-ai/opencode/internal/version"
+)
+
+const (
+	logoBreakpoint = 65
+)
+
+type Sidebar interface {
+	util.Model
+	layout.Sizeable
+}
+
+type sidebarCmp struct {
+	width, height int
+	session       session.Session
+	logo          string
+	cwd           string
+}
+
+func NewSidebarCmp() Sidebar {
+	return &sidebarCmp{}
+}
+
+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:
+		if msg.ID != m.session.ID {
+			m.session = msg
+		}
+	case pubsub.Event[session.Session]:
+		if msg.Type == pubsub.UpdatedEvent {
+			if m.session.ID == msg.Payload.ID {
+				m.session = msg.Payload
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m *sidebarCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	parts := []string{
+		m.logo,
+	}
+
+	if m.session.ID != "" {
+		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
+	}
+
+	parts = append(parts,
+		m.cwd,
+		"",
+		m.lspBlock(),
+		"",
+		m.mcpBlock(),
+	)
+
+	return tea.NewView(
+		lipgloss.JoinVertical(lipgloss.Left, parts...),
+	)
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+	if width < logoBreakpoint && m.width >= logoBreakpoint {
+		m.logo = m.logoBlock(true)
+	} else if width >= logoBreakpoint && m.width < logoBreakpoint {
+		m.logo = m.logoBlock(false)
+	}
+
+	m.width = width
+	m.height = height
+	return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+func (m *sidebarCmp) logoBlock(compact bool) string {
+	t := styles.CurrentTheme()
+	return logo.Render(version.Version, compact, logo.Opts{
+		FieldColor:   t.Primary,
+		TitleColorA:  t.Secondary,
+		TitleColorB:  t.Primary,
+		CharmColor:   t.Secondary,
+		VersionColor: t.Primary,
+	})
+}
+
+func (m *sidebarCmp) lspBlock() string {
+	maxWidth := min(m.width, 58)
+	t := styles.CurrentTheme()
+
+	section := t.S().Muted.Render(
+		core.Section("Configured LSPs", maxWidth),
+	)
+
+	lspList := []string{section, ""}
+
+	lsp := config.Get().LSP
+	if len(lsp) == 0 {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Muted.Render("No LSPs configured."),
+		)
+	}
+
+	for n, l := range lsp {
+		iconColor := t.Success
+		if l.Disabled {
+			iconColor = t.FgMuted
+		}
+		lspList = append(lspList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       n,
+					Description: l.Command,
+				},
+				m.width,
+			),
+		)
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		lspList...,
+	)
+}
+
+func (m *sidebarCmp) mcpBlock() string {
+	maxWidth := min(m.width, 58)
+	t := styles.CurrentTheme()
+
+	section := t.S().Muted.Render(
+		core.Section("Configured MCPs", maxWidth),
+	)
+
+	mcpList := []string{section, ""}
+
+	mcp := config.Get().MCPServers
+	if len(mcp) == 0 {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Muted.Render("No MCPs configured."),
+		)
+	}
+
+	for n, l := range mcp {
+		iconColor := t.Success
+		mcpList = append(mcpList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       n,
+					Description: l.Command,
+				},
+				m.width,
+			),
+		)
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		mcpList...,
+	)
+}
+
+func cwd() string {
+	cwd := config.WorkingDirectory()
+	t := styles.CurrentTheme()
+	// replace home directory with ~
+	homeDir, err := os.UserHomeDir()
+	if err == nil {
+		cwd = strings.ReplaceAll(cwd, homeDir, "~")
+	}
+	return t.S().Muted.Render(cwd)
+}

internal/tui/components/core/helpers.go 🔗

@@ -0,0 +1,63 @@
+package core
+
+import (
+	"image/color"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+)
+
+func Section(title string, width int) string {
+	t := styles.CurrentTheme()
+	char := "─"
+	length := len(title) + 1
+	remainingWidth := width - length
+	if remainingWidth > 0 {
+		title = title + " " + t.S().Subtle.Render(strings.Repeat(char, remainingWidth))
+	}
+	return title
+}
+
+type StatusOpts struct {
+	Icon             string
+	IconColor        color.Color
+	Title            string
+	TitleColor       color.Color
+	Description      string
+	DescriptionColor color.Color
+}
+
+func Status(ops StatusOpts, width int) string {
+	t := styles.CurrentTheme()
+	icon := "●"
+	iconColor := t.Success
+	if ops.Icon != "" {
+		icon = ops.Icon
+	}
+	if ops.IconColor != nil {
+		iconColor = ops.IconColor
+	}
+	title := ops.Title
+	titleColor := t.FgMuted
+	if ops.TitleColor != nil {
+		titleColor = ops.TitleColor
+	}
+	description := ops.Description
+	descriptionColor := t.FgSubtle
+	if ops.DescriptionColor != nil {
+		descriptionColor = ops.DescriptionColor
+	}
+	icon = t.S().Base.Foreground(iconColor).Render(icon)
+	title = t.S().Base.Foreground(titleColor).Render(title)
+	if description != "" {
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
+	}
+	description = t.S().Base.Foreground(descriptionColor).Render(description)
+	return strings.Join([]string{
+		icon,
+		title,
+		description,
+	}, " ")
+}

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

@@ -63,7 +63,7 @@ func Render(version string, compact bool, o Opts) string {
 	// Narrow version.
 	if compact {
 		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
-		return strings.Join([]string{field, field, crush, field}, "\n")
+		return strings.Join([]string{field, field, crush, field, ""}, "\n")
 	}
 
 	fieldHeight := lipgloss.Height(crush)

internal/tui/layout/container.go 🔗

@@ -4,7 +4,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
@@ -46,12 +46,11 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (c *container) View() tea.View {
-	t := theme.CurrentTheme()
-	style := lipgloss.NewStyle()
+	t := styles.CurrentTheme()
 	width := c.width
 	height := c.height
 
-	style = style.Background(t.Background())
+	style := t.S().Base
 
 	// Apply border if any side is enabled
 	if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -69,7 +68,7 @@ func (c *container) View() tea.View {
 			width--
 		}
 		style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
-		style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
+		style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
 	}
 	style = style.
 		Width(width).

internal/tui/layout/split.go 🔗

@@ -22,14 +22,18 @@ type SplitPaneLayout interface {
 }
 
 type splitPaneLayout struct {
-	width         int
-	height        int
+	width  int
+	height int
+
 	ratio         float64
 	verticalRatio float64
 
 	rightPanel  Container
 	leftPanel   Container
 	bottomPanel Container
+
+	fixedBottomHeight int // Fixed height for the bottom panel, if any
+	fixedRightWidth   int // Fixed width for the right panel, if any
 }
 
 type SplitPaneOption func(*splitPaneLayout)
@@ -141,8 +145,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	var topHeight, bottomHeight int
 	var cmds []tea.Cmd
 	if s.bottomPanel != nil {
-		topHeight = int(float64(height) * s.verticalRatio)
-		bottomHeight = height - topHeight
+		if s.fixedBottomHeight > 0 {
+			bottomHeight = s.fixedBottomHeight
+			topHeight = height - bottomHeight
+		} else {
+			topHeight = int(float64(height) * s.verticalRatio)
+			bottomHeight = height - topHeight
+			if bottomHeight <= 0 {
+				bottomHeight = 2
+				topHeight = height - bottomHeight
+			}
+		}
 	} else {
 		topHeight = height
 		bottomHeight = 0
@@ -150,8 +163,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 
 	var leftWidth, rightWidth int
 	if s.leftPanel != nil && s.rightPanel != nil {
-		leftWidth = int(float64(width) * s.ratio)
-		rightWidth = width - leftWidth
+		if s.fixedRightWidth > 0 {
+			rightWidth = s.fixedRightWidth
+			leftWidth = width - rightWidth
+		} else {
+			leftWidth = int(float64(width) * s.ratio)
+			rightWidth = width - leftWidth
+			if rightWidth <= 0 {
+				rightWidth = 2
+				leftWidth = width - rightWidth
+			}
+		}
 	} else if s.leftPanel != nil {
 		leftWidth = width
 		rightWidth = 0
@@ -260,8 +282,8 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
 
 func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
 	layout := &splitPaneLayout{
-		ratio:         0.7,
-		verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
+		ratio:         0.8,
+		verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
 	}
 	for _, option := range options {
 		option(layout)
@@ -298,3 +320,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
 		s.verticalRatio = ratio
 	}
 }
+
+func WithFixedBottomHeight(height int) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.fixedBottomHeight = height
+	}
+}
+
+func WithFixedRightWidth(width int) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.fixedRightWidth = width
+	}
+}

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

@@ -0,0 +1,173 @@
+package chat
+
+import (
+	"context"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat/sidebar"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/page"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+var ChatPage page.PageID = "chat"
+
+type chatPage struct {
+	app *app.App
+
+	layout layout.SplitPaneLayout
+
+	session session.Session
+}
+
+type ChatKeyMap struct {
+	NewSession key.Binding
+	Cancel     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"),
+	),
+}
+
+func (p *chatPage) Init() tea.Cmd {
+	return p.layout.Init()
+}
+
+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:
+		cmd := p.sendMessage(msg.Text, msg.Attachments)
+		if cmd != nil {
+			return p, cmd
+		}
+	case commands.CommandRunCustomMsg:
+		// Check if the agent is busy before executing custom commands
+		if p.app.CoderAgent.IsBusy() {
+			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
+		}
+
+		// Handle custom command execution
+		cmd := p.sendMessage(msg.Content, nil)
+		if cmd != nil {
+			return p, cmd
+		}
+	case chat.SessionSelectedMsg:
+		if p.session.ID == "" {
+			cmd := p.setMessages()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+		p.session = msg
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, keyMap.NewSession):
+			p.session = session.Session{}
+			return p, tea.Batch(
+				p.clearMessages(),
+				util.CmdHandler(chat.SessionClearedMsg{}),
+			)
+		case key.Matches(msg, keyMap.Cancel):
+			if p.session.ID != "" {
+				// Cancel the current session's generation process
+				// This allows users to interrupt long-running operations
+				p.app.CoderAgent.Cancel(p.session.ID)
+				return p, nil
+			}
+		}
+	}
+	u, cmd := p.layout.Update(msg)
+	cmds = append(cmds, cmd)
+	p.layout = u.(layout.SplitPaneLayout)
+
+	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) clearMessages() tea.Cmd {
+	return p.layout.ClearLeftPanel()
+}
+
+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+	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)
+		}
+
+		p.session = session
+		cmd := p.setMessages()
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+	}
+
+	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	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() tea.View {
+	return p.layout.View()
+}
+
+func (p *chatPage) BindingKeys() []key.Binding {
+	bindings := layout.KeyMapToSlice(keyMap)
+	return bindings
+}
+
+func NewChatPage(app *app.App) util.Model {
+	sidebarContainer := layout.NewContainer(
+		sidebar.NewSidebarCmp(),
+		layout.WithPadding(1, 1, 1, 1),
+	)
+	editorContainer := layout.NewContainer(
+		editor.NewEditorCmp(app),
+	)
+	return &chatPage{
+		app: app,
+		layout: layout.NewSplitPane(
+			layout.WithRightPanel(sidebarContainer),
+			layout.WithBottomPanel(editorContainer),
+			layout.WithFixedBottomHeight(3),
+			layout.WithFixedRightWidth(31),
+		),
+	}
+}

internal/tui/styles/crush.go 🔗

@@ -0,0 +1,44 @@
+package styles
+
+import (
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+func NewCrushTheme() *Theme {
+	return &Theme{
+		Name:   "crush",
+		IsDark: true,
+
+		Primary:   lipgloss.Color(charmtone.Charple.Hex()),
+		Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
+		Tertiary:  lipgloss.Color(charmtone.Bok.Hex()),
+		Accent:    lipgloss.Color(charmtone.Zest.Hex()),
+
+		// Backgrounds
+		BgBase:    lipgloss.Color(charmtone.Pepper.Hex()),
+		BgSubtle:  lipgloss.Color(charmtone.Charcoal.Hex()),
+		BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
+
+		// Foregrounds
+		FgBase:   lipgloss.Color(charmtone.Ash.Hex()),
+		FgMuted:  lipgloss.Color(charmtone.Squid.Hex()),
+		FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
+
+		// Borders
+		Border:      lipgloss.Color(charmtone.Charcoal.Hex()),
+		BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
+
+		// Status
+		Success: lipgloss.Color(charmtone.Guac.Hex()),
+		Error:   lipgloss.Color(charmtone.Sriracha.Hex()),
+		Warning: lipgloss.Color(charmtone.Uni.Hex()),
+		Info:    lipgloss.Color(charmtone.Malibu.Hex()),
+
+		// TODO: fix this.
+		SyntaxBg:      lipgloss.Color("#1C1C1F"),
+		SyntaxKeyword: lipgloss.Color("#FF6DFE"),
+		SyntaxString:  lipgloss.Color("#E8FE96"),
+		SyntaxComment: lipgloss.Color("#6B6F85"),
+	}
+}

internal/tui/styles/theme.go 🔗

@@ -0,0 +1,229 @@
+package styles
+
+import (
+	"fmt"
+	"image/color"
+
+	"github.com/charmbracelet/bubbles/v2/textarea"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type Theme struct {
+	Name   string
+	IsDark bool
+
+	Primary   color.Color
+	Secondary color.Color
+	Tertiary  color.Color
+	Accent    color.Color
+
+	BgBase    color.Color
+	BgSubtle  color.Color
+	BgOverlay color.Color
+
+	FgBase   color.Color
+	FgMuted  color.Color
+	FgSubtle color.Color
+
+	Border      color.Color
+	BorderFocus color.Color
+
+	Success color.Color
+	Error   color.Color
+	Warning color.Color
+	Info    color.Color
+
+	// TODO: add more syntax colors, maybe just use a chroma theme here.
+	SyntaxBg      color.Color
+	SyntaxKeyword color.Color
+	SyntaxString  color.Color
+	SyntaxComment color.Color
+
+	styles *Styles
+}
+
+type Styles struct {
+	Base lipgloss.Style
+
+	Title    lipgloss.Style
+	Subtitle lipgloss.Style
+	Text     lipgloss.Style
+	Muted    lipgloss.Style
+	Subtle   lipgloss.Style
+
+	Success lipgloss.Style
+	Error   lipgloss.Style
+	Warning lipgloss.Style
+	Info    lipgloss.Style
+
+	// Inputs
+	TextArea textarea.Styles
+}
+
+func (t *Theme) S() *Styles {
+	if t.styles == nil {
+		t.styles = t.buildStyles()
+	}
+	return t.styles
+}
+
+func (t *Theme) buildStyles() *Styles {
+	base := lipgloss.NewStyle().
+		Background(t.BgBase).
+		Foreground(t.FgBase)
+	return &Styles{
+		Base: base,
+
+		Title: base.
+			Foreground(t.Accent).
+			Bold(true),
+
+		Subtitle: base.
+			Foreground(t.Secondary).
+			Bold(true),
+
+		Text: base,
+
+		Muted: base.Foreground(t.FgMuted),
+
+		Subtle: base.Foreground(t.FgSubtle),
+
+		Success: base.Foreground(t.Success),
+
+		Error: base.Foreground(t.Error),
+
+		Warning: base.Foreground(t.Warning),
+
+		Info: base.Foreground(t.Info),
+
+		TextArea: textarea.Styles{
+			Focused: textarea.StyleState{
+				Base:             base,
+				Text:             base,
+				LineNumber:       base.Foreground(t.FgSubtle),
+				CursorLine:       base,
+				CursorLineNumber: base.Foreground(t.FgSubtle),
+				Placeholder:      base.Foreground(t.FgMuted),
+				Prompt:           base.Foreground(t.Tertiary),
+			},
+			Blurred: textarea.StyleState{
+				Base:             base,
+				Text:             base.Foreground(t.FgMuted),
+				LineNumber:       base.Foreground(t.FgMuted),
+				CursorLine:       base,
+				CursorLineNumber: base.Foreground(t.FgMuted),
+				Placeholder:      base.Foreground(t.FgMuted),
+				Prompt:           base.Foreground(t.FgMuted),
+			},
+			Cursor: textarea.CursorStyle{
+				Color: t.Secondary,
+				Shape: tea.CursorBar,
+				Blink: true,
+			},
+		},
+	}
+}
+
+type Manager struct {
+	themes  map[string]*Theme
+	current *Theme
+}
+
+var defaultManager *Manager
+
+func SetDefaultManager(m *Manager) {
+	defaultManager = m
+}
+
+func DefaultManager() *Manager {
+	if defaultManager == nil {
+		defaultManager = NewManager("crush")
+	}
+	return defaultManager
+}
+
+func CurrentTheme() *Theme {
+	if defaultManager == nil {
+		defaultManager = NewManager("crush")
+	}
+	return defaultManager.Current()
+}
+
+func NewManager(defaultTheme string) *Manager {
+	m := &Manager{
+		themes: make(map[string]*Theme),
+	}
+
+	m.Register(NewCrushTheme())
+
+	m.current = m.themes[defaultTheme]
+
+	return m
+}
+
+func (m *Manager) Register(theme *Theme) {
+	m.themes[theme.Name] = theme
+}
+
+func (m *Manager) Current() *Theme {
+	return m.current
+}
+
+func (m *Manager) SetTheme(name string) error {
+	if theme, ok := m.themes[name]; ok {
+		m.current = theme
+		return nil
+	}
+	return fmt.Errorf("theme %s not found", name)
+}
+
+func (m *Manager) List() []string {
+	names := make([]string, 0, len(m.themes))
+	for name := range m.themes {
+		names = append(names, name)
+	}
+	return names
+}
+
+// ParseHex converts hex string to color
+func ParseHex(hex string) color.Color {
+	var r, g, b uint8
+	fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
+	return color.RGBA{R: r, G: g, B: b, A: 255}
+}
+
+// Alpha returns a color with transparency
+func Alpha(c color.Color, alpha uint8) color.Color {
+	r, g, b, _ := c.RGBA()
+	return color.RGBA{
+		R: uint8(r >> 8),
+		G: uint8(g >> 8),
+		B: uint8(b >> 8),
+		A: alpha,
+	}
+}
+
+// Darken makes a color darker by percentage (0-100)
+func Darken(c color.Color, percent float64) color.Color {
+	r, g, b, a := c.RGBA()
+	factor := 1.0 - percent/100.0
+	return color.RGBA{
+		R: uint8(float64(r>>8) * factor),
+		G: uint8(float64(g>>8) * factor),
+		B: uint8(float64(b>>8) * factor),
+		A: uint8(a >> 8),
+	}
+}
+
+// Lighten makes a color lighter by percentage (0-100)
+func Lighten(c color.Color, percent float64) color.Color {
+	r, g, b, a := c.RGBA()
+	factor := percent / 100.0
+	return color.RGBA{
+		R: uint8(min(255, float64(r>>8)+255*factor)),
+		G: uint8(min(255, float64(g>>8)+255*factor)),
+		B: uint8(min(255, float64(b>>8)+255*factor)),
+		A: uint8(a >> 8),
+	}
+}

internal/tui/tui.go 🔗

@@ -14,10 +14,12 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/page"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/opencode-ai/opencode/internal/tui/page/chat"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+// appModel represents the main application model that manages pages, dialogs, and UI state.
 type appModel struct {
 	width, height int
 	keyMap        KeyMap
@@ -35,6 +37,7 @@ type appModel struct {
 	completions completions.Completions
 }
 
+// Init initializes the application model and returns initial commands.
 func (a appModel) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	cmd := a.pages[a.currentPage].Init()
@@ -46,6 +49,7 @@ func (a appModel) Init() tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// Update handles incoming messages and updates the application state.
 func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
@@ -111,6 +115,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return a, tea.Batch(cmds...)
 }
 
+// handleWindowResize processes window resize events and updates all components.
 func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
 	var cmds []tea.Cmd
 	msg.Height -= 1 // Make space for the status bar
@@ -134,6 +139,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
 func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	switch {
 	// completions
@@ -182,11 +188,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	}
 }
 
-// RegisterCommand adds a command to the command dialog
-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
-// 	a.commands = append(a.commands, cmd)
-// }
-
+// moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {
 		// For now we don't move to any page if the agent is busy
@@ -209,6 +211,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// View renders the complete application interface including pages, dialogs, and overlays.
 func (a *appModel) View() tea.View {
 	pageView := a.pages[a.currentPage].View()
 	components := []string{
@@ -220,7 +223,6 @@ func (a *appModel) View() tea.View {
 	layers := []*lipgloss.Layer{
 		lipgloss.NewLayer(appView),
 	}
-	t := theme.CurrentTheme()
 	if a.dialog.HasDialogs() {
 		layers = append(
 			layers,
@@ -246,12 +248,15 @@ func (a *appModel) View() tea.View {
 	canvas := lipgloss.NewCanvas(
 		layers...,
 	)
+
+	t := styles.CurrentTheme()
 	view := tea.NewView(canvas.Render())
-	view.SetBackgroundColor(t.Background())
+	view.SetBackgroundColor(t.BgBase)
 	view.SetCursor(cursor)
 	return view
 }
 
+// New creates and initializes a new TUI application model.
 func New(app *app.App) tea.Model {
 	startPage := page.ChatPage
 	model := &appModel{
@@ -262,7 +267,7 @@ func New(app *app.App) tea.Model {
 		keyMap:      DefaultKeyMap(),
 
 		pages: map[page.PageID]util.Model{
-			page.ChatPage: page.NewChatPage(app),
+			page.ChatPage: chat.NewChatPage(app),
 			page.LogsPage: page.NewLogsPage(),
 		},
 

todos.md 🔗

@@ -0,0 +1,8 @@
+# Chat Page
+
+## Landing page
+
+- [ ] Implement the logo landing page
+- [ ] Add cwd improved
+- [ ] Implement Active LSPs
+- [ ] Implement Active MCPs