Explorar el Código

wip: refactoring tui

adamdottv hace 8 meses
padre
commit
95d5e1f231
Se han modificado 37 ficheros con 1489 adiciones y 1794 borrados
  1. 0 70
      README.md
  2. 6 1
      packages/tui/cmd/opencode/main.go
  3. 15 4
      packages/tui/internal/app/app.go
  4. 5 31
      packages/tui/internal/components/chat/cache.go
  5. 0 109
      packages/tui/internal/components/chat/chat.go
  6. 150 104
      packages/tui/internal/components/chat/editor.go
  7. 305 185
      packages/tui/internal/components/chat/message.go
  8. 252 141
      packages/tui/internal/components/chat/messages.go
  9. 0 212
      packages/tui/internal/components/chat/sidebar.go
  10. 91 81
      packages/tui/internal/components/core/status.go
  11. 1 1
      packages/tui/internal/components/dialog/custom_commands.go
  12. 17 18
      packages/tui/internal/components/diff/diff.go
  13. 0 127
      packages/tui/internal/components/spinner/spinner.go
  14. 0 24
      packages/tui/internal/components/spinner/spinner_test.go
  15. 50 2
      packages/tui/internal/layout/container.go
  16. 248 0
      packages/tui/internal/layout/flex.go
  17. 29 0
      packages/tui/internal/layout/layout.go
  18. 2 5
      packages/tui/internal/layout/overlay.go
  19. 0 283
      packages/tui/internal/layout/split.go
  20. 14 32
      packages/tui/internal/page/chat.go
  21. 33 28
      packages/tui/internal/styles/styles.go
  22. 5 9
      packages/tui/internal/theme/ayu.go
  23. 5 9
      packages/tui/internal/theme/catppuccin.go
  24. 8 12
      packages/tui/internal/theme/dracula.go
  25. 5 9
      packages/tui/internal/theme/flexoki.go
  26. 5 9
      packages/tui/internal/theme/gruvbox.go
  27. 10 12
      packages/tui/internal/theme/manager.go
  28. 5 9
      packages/tui/internal/theme/monokai.go
  29. 5 9
      packages/tui/internal/theme/onedark.go
  30. 76 56
      packages/tui/internal/theme/opencode.go
  31. 39 42
      packages/tui/internal/theme/theme.go
  32. 0 89
      packages/tui/internal/theme/theme_test.go
  33. 76 56
      packages/tui/internal/theme/tokyonight.go
  34. 5 9
      packages/tui/internal/theme/tron.go
  35. 23 3
      packages/tui/internal/tui/tui.go
  36. 1 0
      packages/tui/internal/util/util.go
  37. 3 3
      packages/web/src/content/docs/docs/themes.mdx

+ 0 - 70
README.md

@@ -411,76 +411,6 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
 | `fetch` | Fetch data from URLs            | `url` (required), `format` (required), `timeout` (optional) |
 | `agent` | Run sub-tasks with the AI agent | `prompt` (required)                                         |
 
-## Theming
-
-OpenCode supports multiple themes for customizing the appearance of the terminal interface.
-
-### Available Themes
-
-The following predefined themes are available:
-
-- `opencode` (default)
-- `catppuccin`
-- `dracula`
-- `flexoki`
-- `gruvbox`
-- `monokai`
-- `onedark`
-- `tokyonight`
-- `tron`
-- `custom` (user-defined)
-
-### Setting a Theme
-
-You can set a theme in your `.opencode.json` configuration file:
-
-```json
-{
-  "tui": {
-    "theme": "monokai"
-  }
-}
-```
-
-### Custom Themes
-
-You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
-
-```json
-{
-  "tui": {
-    "theme": "custom",
-    "customTheme": {
-      "primary": "#ffcc00",
-      "secondary": "#00ccff",
-      "accent": { "dark": "#aa00ff", "light": "#ddccff" },
-      "error": "#ff0000"
-    }
-  }
-}
-```
-
-#### Color Definition Formats
-
-Custom theme colors support two formats:
-
-1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
-
-2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
-
-#### Available Color Keys
-
-You can define any of the following color keys in your `customTheme`:
-
-- Base colors: `primary`, `secondary`, `accent`
-- Status colors: `error`, `warning`, `success`, `info`
-- Text colors: `text`, `textMuted`, `textEmphasized`
-- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
-- Border colors: `borderNormal`, `borderFocused`, `borderDim`
-- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
-
-You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
-
 ### Shell Configuration
 
 OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:

+ 6 - 1
packages/tui/cmd/opencode/main.go

@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 
@@ -51,7 +52,11 @@ func main() {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
-	app_, err := app.New(ctx, httpClient)
+	version := Version
+	if version != "dev" && !strings.HasPrefix(Version, "v") {
+		version = "v" + Version
+	}
+	app_, err := app.New(ctx, version, httpClient)
 	if err != nil {
 		panic(err)
 	}

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

@@ -21,7 +21,6 @@ import (
 type App struct {
 	ConfigPath string
 	Config     *config.Config
-	Info       *client.AppInfo
 	Client     *client.ClientWithResponses
 	Provider   *client.ProviderInfo
 	Model      *client.ProviderModel
@@ -34,7 +33,14 @@ type App struct {
 	completionDialogOpen bool
 }
 
-func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, error) {
+type AppInfo struct {
+	client.AppInfo
+	Version string
+}
+
+var Info AppInfo
+
+func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
 	err := status.InitService()
 	if err != nil {
 		slog.Error("Failed to initialize status service", "error", err)
@@ -43,6 +49,12 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
 
 	appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
 	appInfo := appInfoResponse.JSON200
+	Info = AppInfo{Version: version}
+	Info.Git = appInfo.Git
+	Info.Path = appInfo.Path
+	Info.Time = appInfo.Time
+	Info.User = appInfo.User
+
 	providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
 	if err != nil {
 		return nil, err
@@ -70,7 +82,7 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
 		return nil, fmt.Errorf("no providers found")
 	}
 
-	appConfigPath := filepath.Join(appInfo.Path.Config, "tui.toml")
+	appConfigPath := filepath.Join(Info.Path.Config, "tui.toml")
 	appConfig, err := config.LoadConfig(appConfigPath)
 	if err != nil {
 		slog.Info("No TUI config found, using default values", "error", err)
@@ -95,7 +107,6 @@ func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, err
 	app := &App{
 		ConfigPath: appConfigPath,
 		Config:     appConfig,
-		Info:       appInfo,
 		Client:     httpClient,
 		Provider:   currentProvider,
 		Model:      currentModel,

+ 5 - 31
packages/tui/internal/components/chat/cache.go

@@ -5,8 +5,6 @@ import (
 	"encoding/hex"
 	"fmt"
 	"sync"
-
-	"github.com/sst/opencode/pkg/client"
 )
 
 // MessageCache caches rendered messages to avoid re-rendering
@@ -23,51 +21,27 @@ func NewMessageCache() *MessageCache {
 }
 
 // generateKey creates a unique key for a message based on its content and rendering parameters
-func (c *MessageCache) generateKey(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) string {
-	// Create a hash of the message content and rendering parameters
+func (c *MessageCache) GenerateKey(params ...any) string {
 	h := sha256.New()
-
-	// Include message ID and role
-	h.Write(fmt.Appendf(nil, "%s:%s", msg.Id, msg.Role))
-
-	// Include timestamp
-	h.Write(fmt.Appendf(nil, ":%f", msg.Metadata.Time.Created))
-
-	// Include width and showToolMessages flag
-	h.Write(fmt.Appendf(nil, ":%d:%t", width, showToolMessages))
-
-	// Include app path for relative path calculations
-	h.Write([]byte(appInfo.Path.Root))
-
-	// Include message parts
-	for _, part := range msg.Parts {
-		h.Write(fmt.Appendf(nil, ":%v", part))
-	}
-
-	// Include tool metadata if present
-	for toolID, metadata := range msg.Metadata.Tool {
-		h.Write(fmt.Appendf(nil, ":%s:%v", toolID, metadata))
+	for _, param := range params {
+		h.Write(fmt.Appendf(nil, ":%v", param))
 	}
-
 	return hex.EncodeToString(h.Sum(nil))
 }
 
 // Get retrieves a cached rendered message
-func (c *MessageCache) Get(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo) (string, bool) {
+func (c *MessageCache) Get(key string) (string, bool) {
 	c.mu.RLock()
 	defer c.mu.RUnlock()
 
-	key := c.generateKey(msg, width, showToolMessages, appInfo)
 	content, exists := c.cache[key]
 	return content, exists
 }
 
 // Set stores a rendered message in the cache
-func (c *MessageCache) Set(msg client.MessageInfo, width int, showToolMessages bool, appInfo client.AppInfo, content string) {
+func (c *MessageCache) Set(key string, content string) {
 	c.mu.Lock()
 	defer c.mu.Unlock()
-
-	key := c.generateKey(msg, width, showToolMessages, appInfo)
 	c.cache[key] = content
 }
 

+ 0 - 109
packages/tui/internal/components/chat/chat.go

@@ -1,11 +1,6 @@
 package chat
 
 import (
-	"fmt"
-	"sort"
-
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/x/ansi"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
@@ -16,100 +11,6 @@ type SendMsg struct {
 	Attachments []app.Attachment
 }
 
-func header(app *app.App, width int) string {
-	return lipgloss.JoinVertical(
-		lipgloss.Top,
-		logo(width),
-		repo(width),
-		"",
-		cwd(app, width),
-	)
-}
-
-func lspsConfigured(width int) string {
-	// cfg := config.Get()
-	title := "LSP Servers"
-	title = ansi.Truncate(title, width, "…")
-
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	lsps := baseStyle.
-		Width(width).
-		Foreground(t.Primary()).
-		Bold(true).
-		Render(title)
-
-	// Get LSP names and sort them for consistent ordering
-	var lspNames []string
-	// for name := range cfg.LSP {
-	// 	lspNames = append(lspNames, name)
-	// }
-	sort.Strings(lspNames)
-
-	var lspViews []string
-	// for _, name := range lspNames {
-	// lsp := cfg.LSP[name]
-	// lspName := baseStyle.
-	// 	Foreground(t.Text()).
-	// 	Render(fmt.Sprintf("• %s", name))
-
-	// cmd := lsp.Command
-	// cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
-
-	// lspPath := baseStyle.
-	// 	Foreground(t.TextMuted()).
-	// 	Render(fmt.Sprintf(" (%s)", cmd))
-
-	// lspViews = append(lspViews,
-	// 	baseStyle.
-	// 		Width(width).
-	// 		Render(
-	// 			lipgloss.JoinHorizontal(
-	// 				lipgloss.Left,
-	// 				lspName,
-	// 				lspPath,
-	// 			),
-	// 		),
-	// )
-	// }
-
-	return baseStyle.
-		Width(width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				lsps,
-				lipgloss.JoinVertical(
-					lipgloss.Left,
-					lspViews...,
-				),
-			),
-		)
-}
-
-func logo(width int) string {
-	logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	versionText := baseStyle.
-		Foreground(t.TextMuted()).
-		Render("v0.0.1") // TODO: get version from server
-
-	return baseStyle.
-		Bold(true).
-		Width(width).
-		Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				logo,
-				" ",
-				versionText,
-			),
-		)
-}
-
 func repo(width int) string {
 	repo := "github.com/sst/opencode"
 	t := theme.CurrentTheme()
@@ -119,13 +20,3 @@ func repo(width int) string {
 		Width(width).
 		Render(repo)
 }
-
-func cwd(app *app.App, width int) string {
-	cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
-	t := theme.CurrentTheme()
-
-	return styles.BaseStyle().
-		Foreground(t.TextMuted()).
-		Width(width).
-		Render(cwd)
-}

+ 150 - 104
packages/tui/internal/components/chat/editor.go

@@ -10,6 +10,7 @@ import (
 	"unicode"
 
 	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
 	"github.com/charmbracelet/bubbles/textarea"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -23,7 +24,7 @@ import (
 	"github.com/sst/opencode/internal/util"
 )
 
-type editorCmp struct {
+type editorComponent struct {
 	width          int
 	height         int
 	app            *app.App
@@ -33,6 +34,7 @@ type editorCmp struct {
 	history        []string
 	historyIndex   int
 	currentMessage string
+	spinner        spinner.Model
 }
 
 type EditorKeyMaps struct {
@@ -96,86 +98,19 @@ const (
 	maxAttachments = 5
 )
 
-func (m *editorCmp) openEditor(value string) tea.Cmd {
-	editor := os.Getenv("EDITOR")
-	if editor == "" {
-		editor = "nvim"
-	}
-
-	tmpfile, err := os.CreateTemp("", "msg_*.md")
-	tmpfile.WriteString(value)
-	if err != nil {
-		status.Error(err.Error())
-		return nil
-	}
-	tmpfile.Close()
-	c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
-	c.Stdin = os.Stdin
-	c.Stdout = os.Stdout
-	c.Stderr = os.Stderr
-	return tea.ExecProcess(c, func(err error) tea.Msg {
-		if err != nil {
-			status.Error(err.Error())
-			return nil
-		}
-		content, err := os.ReadFile(tmpfile.Name())
-		if err != nil {
-			status.Error(err.Error())
-			return nil
-		}
-		if len(content) == 0 {
-			status.Warn("Message is empty")
-			return nil
-		}
-		os.Remove(tmpfile.Name())
-		attachments := m.attachments
-		m.attachments = nil
-		return SendMsg{
-			Text:        string(content),
-			Attachments: attachments,
-		}
-	})
-}
-
-func (m *editorCmp) Init() tea.Cmd {
-	return textarea.Blink
-}
-
-func (m *editorCmp) send() tea.Cmd {
-	value := m.textarea.Value()
-	m.textarea.Reset()
-	attachments := m.attachments
-
-	// Save to history if not empty and not a duplicate of the last entry
-	if value != "" {
-		if len(m.history) == 0 || m.history[len(m.history)-1] != value {
-			m.history = append(m.history, value)
-		}
-		m.historyIndex = len(m.history)
-		m.currentMessage = ""
-	}
-
-	m.attachments = nil
-	if value == "" {
-		return nil
-	}
-	return tea.Batch(
-		util.CmdHandler(SendMsg{
-			Text:        value,
-			Attachments: attachments,
-		}),
-	)
+func (m *editorComponent) Init() tea.Cmd {
+	return tea.Batch(textarea.Blink, m.spinner.Tick)
 }
 
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+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:
-		m.textarea = CreateTextArea(&m.textarea)
+		m.textarea = createTextArea(&m.textarea)
 	case dialog.CompletionSelectedMsg:
 		existingValue := m.textarea.Value()
 		modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
 		m.textarea.SetValue(modifiedValue)
 		return m, nil
 	case dialog.AttachmentAddedMsg:
@@ -296,47 +231,160 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, m.send()
 			}
 		}
-
 	}
+
+	m.spinner, cmd = m.spinner.Update(msg)
+	cmds = append(cmds, cmd)
+
 	m.textarea, cmd = m.textarea.Update(msg)
-	return m, cmd
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
 }
 
-func (m *editorCmp) View() string {
+func (m *editorComponent) View() string {
 	t := theme.CurrentTheme()
-
-	// Style the prompt with theme colors
-	style := lipgloss.NewStyle().
+	base := styles.BaseStyle().Render
+	muted := styles.Muted().Render
+	promptStyle := lipgloss.NewStyle().
 		Padding(0, 0, 0, 1).
 		Bold(true).
 		Foreground(t.Primary())
+	prompt := promptStyle.Render(">")
 
-	if len(m.attachments) == 0 {
-		return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+	textarea := lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		prompt,
+		m.textarea.View(),
+	)
+	textarea = styles.BaseStyle().
+		Width(m.width-2).
+		Border(lipgloss.NormalBorder(), true, true).
+		BorderForeground(t.Border()).
+		Render(textarea)
+
+	hint := base("enter") + muted(" send   ") + base("shift") + muted("+") + base("enter") + muted(" newline")
+	if m.app.IsBusy() {
+		hint = muted("working") + m.spinner.View() + muted("  ") + base("esc") + muted(" interrupt")
+	}
+
+	model := ""
+	if m.app.Model != nil {
+		model = base(*m.app.Model.Name) + muted(" • /model")
 	}
-	m.textarea.SetHeight(m.height - 1)
-	return lipgloss.JoinVertical(lipgloss.Top,
-		m.attachmentsContent(),
-		lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
-			m.textarea.View()),
+
+	space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
+	spacer := lipgloss.NewStyle().Width(space).Render("")
+
+	info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
+	info = styles.Padded().Render(info)
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Top,
+		// m.attachmentsContent(),
+		textarea,
+		info,
+	)
+
+	return styles.ForceReplaceBackgroundWithLipgloss(
+		content,
+		t.Background(),
 	)
 }
 
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
+func (m *editorComponent) 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 - 5)   // account for the prompt and padding right
+	m.textarea.SetHeight(height - 3) // account for info underneath
 	return nil
 }
 
-func (m *editorCmp) GetSize() (int, int) {
-	return m.textarea.Width(), m.textarea.Height()
+func (m *editorComponent) GetSize() (int, int) {
+	return m.width, m.height
 }
 
-func (m *editorCmp) attachmentsContent() string {
-	var styledAttachments []string
+func (m *editorComponent) BindingKeys() []key.Binding {
+	bindings := []key.Binding{}
+	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
+	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
+	return bindings
+}
+
+func (m *editorComponent) openEditor(value string) tea.Cmd {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "nvim"
+	}
+
+	tmpfile, err := os.CreateTemp("", "msg_*.md")
+	tmpfile.WriteString(value)
+	if err != nil {
+		status.Error(err.Error())
+		return nil
+	}
+	tmpfile.Close()
+	c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+	c.Stdin = os.Stdin
+	c.Stdout = os.Stdout
+	c.Stderr = os.Stderr
+	return tea.ExecProcess(c, func(err error) tea.Msg {
+		if err != nil {
+			status.Error(err.Error())
+			return nil
+		}
+		content, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			status.Error(err.Error())
+			return nil
+		}
+		if len(content) == 0 {
+			status.Warn("Message is empty")
+			return nil
+		}
+		os.Remove(tmpfile.Name())
+		attachments := m.attachments
+		m.attachments = nil
+		return SendMsg{
+			Text:        string(content),
+			Attachments: attachments,
+		}
+	})
+}
+
+func (m *editorComponent) send() tea.Cmd {
+	value := m.textarea.Value()
+	m.textarea.Reset()
+	attachments := m.attachments
+
+	// Save to history if not empty and not a duplicate of the last entry
+	if value != "" {
+		if len(m.history) == 0 || m.history[len(m.history)-1] != value {
+			m.history = append(m.history, value)
+		}
+		m.historyIndex = len(m.history)
+		m.currentMessage = ""
+	}
+
+	m.attachments = nil
+	if value == "" {
+		return nil
+	}
+	return tea.Batch(
+		util.CmdHandler(SendMsg{
+			Text:        value,
+			Attachments: attachments,
+		}),
+	)
+}
+
+func (m *editorComponent) attachmentsContent() string {
+	if len(m.attachments) == 0 {
+		return ""
+	}
+
 	t := theme.CurrentTheme()
+	var styledAttachments []string
 	attachmentStyles := styles.BaseStyle().
 		MarginLeft(1).
 		Background(t.TextMuted()).
@@ -357,20 +405,15 @@ func (m *editorCmp) attachmentsContent() string {
 	return content
 }
 
-func (m *editorCmp) BindingKeys() []key.Binding {
-	bindings := []key.Binding{}
-	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
-	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
-	return bindings
-}
-
-func CreateTextArea(existing *textarea.Model) textarea.Model {
+func createTextArea(existing *textarea.Model) textarea.Model {
 	t := theme.CurrentTheme()
 	bgColor := t.Background()
 	textColor := t.Text()
 	textMutedColor := t.TextMuted()
 
 	ta := textarea.New()
+	ta.Placeholder = "It's prompting time..."
+
 	ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
 	ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
 	ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
@@ -394,13 +437,16 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 	return ta
 }
 
-func NewEditorCmp(app *app.App) tea.Model {
-	ta := CreateTextArea(nil)
-	return &editorCmp{
+func NewEditorComponent(app *app.App) tea.Model {
+	s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
+	ta := createTextArea(nil)
+
+	return &editorComponent{
 		app:            app,
 		textarea:       ta,
 		history:        []string{},
 		historyIndex:   0,
 		currentMessage: "",
+		spinner:        s,
 	}
 }

+ 305 - 185
packages/tui/internal/components/chat/message.go

@@ -2,13 +2,18 @@ package chat
 
 import (
 	"fmt"
+	"log/slog"
 	"path/filepath"
+	"slices"
 	"strings"
 	"time"
+	"unicode"
 
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/diff"
+	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/pkg/client"
@@ -16,14 +21,12 @@ import (
 	"golang.org/x/text/language"
 )
 
-const (
-	maxResultHeight = 10
-)
-
 func toMarkdown(content string, width int) string {
 	r := styles.GetMarkdownRenderer(width)
+	content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
 	rendered, _ := r.Render(content)
 	lines := strings.Split(rendered, "\n")
+
 	if len(lines) > 0 {
 		firstLine := lines[0]
 		cleaned := ansi.Strip(firstLine)
@@ -40,139 +43,204 @@ func toMarkdown(content string, width int) string {
 			}
 		}
 	}
-	return strings.TrimSuffix(strings.Join(lines, "\n"), "\n")
+
+	content = strings.Join(lines, "\n")
+	return strings.TrimSuffix(content, "\n")
 }
 
-func renderUserMessage(user string, msg client.MessageInfo, width int) string {
+type markdownRenderer struct {
+	align         *lipgloss.Position
+	borderColor   *lipgloss.AdaptiveColor
+	fullWidth     bool
+	paddingTop    int
+	paddingBottom int
+}
+
+type markdownRenderingOption func(*markdownRenderer)
+
+func WithFullWidth() markdownRenderingOption {
+	return func(c *markdownRenderer) {
+		c.fullWidth = true
+	}
+}
+
+func WithAlign(align lipgloss.Position) markdownRenderingOption {
+	return func(c *markdownRenderer) {
+		c.align = &align
+	}
+}
+
+func WithBorderColor(color lipgloss.AdaptiveColor) markdownRenderingOption {
+	return func(c *markdownRenderer) {
+		c.borderColor = &color
+	}
+}
+
+func WithPaddingTop(padding int) markdownRenderingOption {
+	return func(c *markdownRenderer) {
+		c.paddingTop = padding
+	}
+}
+
+func WithPaddingBottom(padding int) markdownRenderingOption {
+	return func(c *markdownRenderer) {
+		c.paddingBottom = padding
+	}
+}
+
+func renderMarkdown(content string, options ...markdownRenderingOption) string {
 	t := theme.CurrentTheme()
+	renderer := &markdownRenderer{
+		fullWidth: false,
+	}
+	for _, option := range options {
+		option(renderer)
+	}
+
 	style := styles.BaseStyle().
-		PaddingLeft(1).
-		BorderLeft(true).
+		PaddingTop(1).
+		PaddingBottom(1).
+		PaddingLeft(2).
+		PaddingRight(2).
+		Background(t.BackgroundSubtle()).
 		Foreground(t.TextMuted()).
-		BorderForeground(t.Secondary()).
 		BorderStyle(lipgloss.ThickBorder())
 
-	// var styledAttachments []string
-	// attachmentStyles := baseStyle.
-	// 	MarginLeft(1).
-	// 	Background(t.TextMuted()).
-	// 	Foreground(t.Text())
-	// for _, attachment := range msg.BinaryContent() {
-	// 	file := filepath.Base(attachment.Path)
-	// 	var filename string
-	// 	if len(file) > 10 {
-	// 		filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
-	// 	} else {
-	// 		filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
-	// 	}
-	// 	styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
-	// }
+	align := lipgloss.Left
+	if renderer.align != nil {
+		align = *renderer.align
+	}
 
-	timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
-	if time.Now().Format("02 Jan 2006") == timestamp[:11] {
-		timestamp = timestamp[12:]
+	borderColor := t.BackgroundSubtle()
+	if renderer.borderColor != nil {
+		borderColor = *renderer.borderColor
 	}
-	info := styles.BaseStyle().
-		Foreground(t.TextMuted()).
-		Render(fmt.Sprintf("%s (%s)", user, timestamp))
-
-	content := ""
-	// if len(styledAttachments) > 0 {
-	// 	attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
-	// 	content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
-	// } else {
-	for _, p := range msg.Parts {
-		part, err := p.ValueByDiscriminator()
-		if err != nil {
-			continue //TODO: handle error?
-		}
 
-		switch part.(type) {
-		case client.MessagePartText:
-			textPart := part.(client.MessagePartText)
-			text := toMarkdown(textPart.Text, width)
-			content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
-		}
+	switch align {
+	case lipgloss.Left:
+		style = style.
+			BorderLeft(true).
+			BorderRight(true).
+			AlignHorizontal(align).
+			BorderLeftForeground(borderColor).
+			BorderLeftBackground(t.Background()).
+			BorderRightForeground(t.BackgroundSubtle()).
+			BorderRightBackground(t.Background())
+	case lipgloss.Right:
+		style = style.
+			BorderRight(true).
+			BorderLeft(true).
+			AlignHorizontal(align).
+			BorderRightForeground(borderColor).
+			BorderRightBackground(t.Background()).
+			BorderLeftForeground(t.BackgroundSubtle()).
+			BorderLeftBackground(t.Background())
 	}
 
-	return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+	content = styles.ForceReplaceBackgroundWithLipgloss(content, t.BackgroundSubtle())
+	if renderer.fullWidth {
+		style = style.Width(layout.Current.Container.Width - 2)
+	}
+	content = style.Render(content)
+	if renderer.paddingTop > 0 {
+		content = strings.Repeat("\n", renderer.paddingTop) + content
+	}
+	if renderer.paddingBottom > 0 {
+		content = content + strings.Repeat("\n", renderer.paddingBottom)
+	}
+	content = lipgloss.PlaceHorizontal(
+		layout.Current.Container.Width,
+		align,
+		content,
+		lipgloss.WithWhitespaceBackground(t.Background()),
+	)
+	content = lipgloss.PlaceHorizontal(
+		layout.Current.Viewport.Width,
+		lipgloss.Center,
+		content,
+		lipgloss.WithWhitespaceBackground(t.Background()),
+	)
+	return content
 }
 
-func renderAssistantMessage(
-	msg client.MessageInfo,
-	width int,
-	showToolMessages bool,
-	appInfo client.AppInfo,
-) string {
+func renderText(message client.MessageInfo, text string, author string) string {
 	t := theme.CurrentTheme()
-	style := styles.BaseStyle().
-		PaddingLeft(1).
-		BorderLeft(true).
-		Foreground(t.TextMuted()).
-		BorderForeground(t.Primary()).
-		BorderStyle(lipgloss.ThickBorder())
-	messages := []string{}
+	width := layout.Current.Container.Width
+	padding := 0
+	switch layout.Current.Size {
+	case layout.LayoutSizeSmall:
+		padding = 5
+	case layout.LayoutSizeNormal:
+		padding = 10
+	case layout.LayoutSizeLarge:
+		padding = 15
+	}
 
-	timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
+	timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
 	if time.Now().Format("02 Jan 2006") == timestamp[:11] {
+		// don't show the date if it's today
 		timestamp = timestamp[12:]
 	}
-	modelName := msg.Metadata.Assistant.ModelID
 	info := styles.BaseStyle().
 		Foreground(t.TextMuted()).
-		Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
-
-	for _, p := range msg.Parts {
-		part, err := p.ValueByDiscriminator()
-		if err != nil {
-			continue //TODO: handle error?
-		}
+		Render(fmt.Sprintf("%s (%s)", author, timestamp))
 
-		switch part.(type) {
-		// case client.MessagePartReasoning:
-		// 	reasoningPart := part.(client.MessagePartReasoning)
+	align := lipgloss.Left
+	switch message.Role {
+	case client.User:
+		align = lipgloss.Right
+	case client.Assistant:
+		align = lipgloss.Left
+	}
 
-		case client.MessagePartText:
-			textPart := part.(client.MessagePartText)
-			text := toMarkdown(textPart.Text, width)
-			content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
-			message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
-			messages = append(messages, message)
+	textWidth := lipgloss.Width(text)
+	markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
+	content := toMarkdown(text, markdownWidth)
+	content = lipgloss.JoinVertical(align, content, info)
 
-		case client.MessagePartToolInvocation:
-			if !showToolMessages {
-				continue
-			}
+	switch message.Role {
+	case client.User:
+		return renderMarkdown(content,
+			WithAlign(lipgloss.Right),
+			WithBorderColor(t.Secondary()),
+		)
+	case client.Assistant:
+		return renderMarkdown(content,
+			WithAlign(lipgloss.Left),
+			WithBorderColor(t.Primary()),
+		)
+	}
+	return ""
+}
 
-			toolInvocationPart := part.(client.MessagePartToolInvocation)
-			toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
-			var result *string
-			resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
-			if resultError == nil {
-				result = &resultPart.Result
-			}
-			metadata := map[string]any{}
-			if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
-				metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
-			}
-			message := renderToolInvocation(toolCall, result, metadata, appInfo, width)
-			messages = append(messages, message)
-		}
+func renderToolInvocation(
+	toolCall client.MessageToolInvocationToolCall,
+	result *string,
+	metadata map[string]any,
+	showResult bool,
+) string {
+	ignoredTools := []string{"opencode_todoread"}
+	if slices.Contains(ignoredTools, toolCall.ToolName) {
+		return ""
 	}
 
-	return strings.Join(messages, "\n\n")
-}
+	padding := 1
+	outerWidth := layout.Current.Container.Width - 1 // subtract 1 for the border
+	innerWidth := outerWidth - padding - 4           // -4 for the border and padding
 
-func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, appInfo client.AppInfo, width int) string {
 	t := theme.CurrentTheme()
-	style := styles.BaseStyle().
+	style := styles.Muted().
+		Width(outerWidth).
+		PaddingLeft(padding).
 		BorderLeft(true).
-		PaddingLeft(1).
-		Foreground(t.TextMuted()).
-		BorderForeground(t.TextMuted()).
+		BorderForeground(t.BorderSubtle()).
 		BorderStyle(lipgloss.ThickBorder())
 
-	toolName := renderToolName(toolCall.ToolName)
+	if toolCall.State == "partial-call" {
+		style = style.Foreground(t.TextMuted())
+		return style.Render(renderToolAction(toolCall.ToolName))
+	}
+
 	toolArgs := ""
 	toolArgsMap := make(map[string]any)
 	if toolCall.Args != nil {
@@ -185,17 +253,20 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
 				firstKey = key
 				break
 			}
-			toolArgs = renderArgs(&toolArgsMap, appInfo, firstKey)
+			toolArgs = renderArgs(&toolArgsMap, firstKey)
 		}
 	}
 
-	title := fmt.Sprintf("%s: %s", toolName, toolArgs)
-	finished := result != nil
-	body := styles.BaseStyle().Render("In progress...")
+	if len(toolArgsMap) == 0 {
+		slog.Debug("no args")
+	}
+
+	body := ""
+	finished := result != nil && *result != ""
 	if finished {
 		body = *result
 	}
-	footer := ""
+	elapsed := ""
 	if metadata["time"] != nil {
 		timeMap := metadata["time"].(map[string]any)
 		start := timeMap["start"].(float64)
@@ -206,84 +277,54 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
 		if durationMs > 1000 {
 			roundedDuration = time.Duration(duration.Round(time.Second))
 		}
-		footer = styles.Muted().Render(fmt.Sprintf("%s", roundedDuration))
+		elapsed = styles.Muted().Render(roundedDuration.String())
 	}
 
+	title := ""
 	switch toolCall.ToolName {
+	case "opencode_read":
+		toolArgs = renderArgs(&toolArgsMap, "filePath")
+		title = fmt.Sprintf("Read: %s   %s", toolArgs, elapsed)
+		body = ""
+		filename := toolArgsMap["filePath"].(string)
+		if metadata["preview"] != nil {
+			body = metadata["preview"].(string)
+			body = renderFile(filename, body, WithTruncate(6))
+		}
 	case "opencode_edit":
 		filename := toolArgsMap["filePath"].(string)
-		filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
-		title = fmt.Sprintf("%s: %s", toolName, filename)
-		if finished && metadata["diff"] != nil {
+		title = fmt.Sprintf("Edit: %s   %s", relative(filename), elapsed)
+		if metadata["diff"] != nil {
 			patch := metadata["diff"].(string)
-			formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
+			diffWidth := min(layout.Current.Viewport.Width, 120)
+			formattedDiff, _ := diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
 			body = strings.TrimSpace(formattedDiff)
-			return style.Render(lipgloss.JoinVertical(lipgloss.Left,
-				title,
+			body = lipgloss.Place(
+				layout.Current.Viewport.Width,
+				lipgloss.Height(body)+2,
+				lipgloss.Center,
+				lipgloss.Center,
 				body,
-				styles.ForceReplaceBackgroundWithLipgloss(footer, t.Background()),
-			))
-		}
-	case "opencode_read":
-		toolArgs = renderArgs(&toolArgsMap, appInfo, "filePath")
-		title = fmt.Sprintf("%s: %s", toolName, toolArgs)
-		filename := toolArgsMap["filePath"].(string)
-		ext := filepath.Ext(filename)
-		if ext == "" {
-			ext = ""
-		} else {
-			ext = strings.ToLower(ext[1:])
-		}
-		if finished {
-			if metadata["preview"] != nil {
-				body = metadata["preview"].(string)
-			}
-			body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
-			body = toMarkdown(body, width)
+				lipgloss.WithWhitespaceBackground(t.Background()),
+			)
 		}
 	case "opencode_write":
 		filename := toolArgsMap["filePath"].(string)
-		filename = strings.TrimPrefix(filename, appInfo.Path.Root+"/")
-		title = fmt.Sprintf("%s: %s", toolName, filename)
-		ext := filepath.Ext(filename)
-		if ext == "" {
-			ext = ""
-		} else {
-			ext = strings.ToLower(ext[1:])
-		}
+		title = fmt.Sprintf("Write: %s   %s", relative(filename), elapsed)
 		content := toolArgsMap["content"].(string)
-		body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(content, 10))
-		body = toMarkdown(body, width)
+		body = renderFile(filename, content)
 	case "opencode_bash":
-		if finished && metadata["stdout"] != nil {
-			description := toolArgsMap["description"].(string)
-			title = fmt.Sprintf("%s: %s", toolName, description)
+		description := toolArgsMap["description"].(string)
+		title = fmt.Sprintf("Shell: %s   %s", description, elapsed)
+		if metadata["stdout"] != nil {
 			command := toolArgsMap["command"].(string)
 			stdout := metadata["stdout"].(string)
-			body = fmt.Sprintf("```console\n$ %s\n%s```", command, stdout)
-			body = toMarkdown(body, width)
-		}
-	case "opencode_todoread":
-		title = fmt.Sprintf("%s", toolName)
-		if finished && metadata["todos"] != nil {
-			body = ""
-			todos := metadata["todos"].([]any)
-			for _, todo := range todos {
-				t := todo.(map[string]any)
-				content := t["content"].(string)
-				switch t["status"].(string) {
-				case "completed":
-					body += fmt.Sprintf("- [x] %s\n", content)
-				// case "in-progress":
-				// 	body += fmt.Sprintf("- [ ] _%s_\n", content)
-				default:
-					body += fmt.Sprintf("- [ ] %s\n", content)
-				}
-			}
-			body = toMarkdown(body, width)
+			body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
+			body = toMarkdown(body, innerWidth)
+			body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
 		}
 	case "opencode_todowrite":
-		title = fmt.Sprintf("%s", toolName)
+		title = fmt.Sprintf("Planning...   %s", elapsed)
 		if finished && metadata["todos"] != nil {
 			body = ""
 			todos := metadata["todos"].([]any)
@@ -299,23 +340,35 @@ func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result
 					body += fmt.Sprintf("- [ ] %s\n", content)
 				}
 			}
-			body = toMarkdown(body, width)
+			body = toMarkdown(body, innerWidth)
+			body = renderMarkdown(body, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
 		}
 	default:
-		body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
-		body = toMarkdown(body, width)
+		toolName := renderToolName(toolCall.ToolName)
+		title = style.Render(fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed))
+		// return title
+
+		// toolName := renderToolName(toolCall.ToolName)
+		// title = fmt.Sprintf("%s: %s", toolName, toolArgs)
+		// body = fmt.Sprintf("```txt\n%s\n```", truncateHeight(body, 10))
+		// body = toMarkdown(body, contentWidth)
 	}
 
 	if metadata["error"] != nil && metadata["message"] != nil {
-		body = styles.BaseStyle().Foreground(t.Error()).Render(metadata["message"].(string))
+		body = styles.BaseStyle().
+			Width(outerWidth).
+			Foreground(t.Error()).
+			Render(metadata["message"].(string))
 	}
 
-	content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
-		title,
-		body,
-		footer,
-	))
-	return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+	content := style.Render(title)
+	content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
+	content = styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+	if showResult && body != "" {
+		content += "\n" + body
+	}
+	return content
+	// return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
 }
 
 func renderToolName(name string) string {
@@ -327,9 +380,9 @@ func renderToolName(name string) string {
 	case "opencode_webfetch":
 		return "Fetch"
 	case "opencode_todoread":
-		return "Read TODOs"
+		return "Planning"
 	case "opencode_todowrite":
-		return "Update TODOs"
+		return "Planning"
 	default:
 		normalizedName := name
 		if strings.HasPrefix(name, "opencode_") {
@@ -339,6 +392,59 @@ func renderToolName(name string) string {
 	}
 }
 
+type fileRenderer struct {
+	filename string
+	content  string
+	height   int
+}
+
+type fileRenderingOption func(*fileRenderer)
+
+func WithTruncate(height int) fileRenderingOption {
+	return func(c *fileRenderer) {
+		c.height = height
+	}
+}
+
+func renderFile(filename string, content string, options ...fileRenderingOption) string {
+	renderer := &fileRenderer{
+		filename: filename,
+		content:  content,
+	}
+	for _, option := range options {
+		option(renderer)
+	}
+
+	// TODO: is this even needed?
+	lines := []string{}
+	for line := range strings.SplitSeq(content, "\n") {
+		line = strings.TrimRightFunc(line, unicode.IsSpace)
+		line = strings.ReplaceAll(line, "\t", "  ")
+		lines = append(lines, line)
+	}
+	content = strings.Join(lines, "\n")
+
+	width := layout.Current.Container.Width - 6
+	if renderer.height > 0 {
+		content = truncateHeight(content, renderer.height)
+	}
+	content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
+	content = toMarkdown(content, width)
+
+	// ensure no line is wider than the width
+	// truncated := []string{}
+	// for line := range strings.SplitSeq(content, "\n") {
+	// 	line = strings.TrimRightFunc(line, unicode.IsSpace)
+	// 	// if lipgloss.Width(line) > width-3 {
+	// 	line = ansi.Truncate(line, width-3, "")
+	// 	// }
+	// 	truncated = append(truncated, line)
+	// }
+	// content = strings.Join(truncated, "\n")
+
+	return renderMarkdown(content, WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
+}
+
 func renderToolAction(name string) string {
 	switch name {
 	// case agent.AgentToolName:
@@ -367,7 +473,7 @@ func renderToolAction(name string) string {
 	return "Working..."
 }
 
-func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) string {
+func renderArgs(args *map[string]any, titleKey string) string {
 	if args == nil || len(*args) == 0 {
 		return ""
 	}
@@ -375,7 +481,7 @@ func renderArgs(args *map[string]any, appInfo client.AppInfo, titleKey string) s
 	parts := []string{}
 	for key, value := range *args {
 		if key == "filePath" || key == "path" {
-			value = strings.TrimPrefix(value.(string), appInfo.Path.Root+"/")
+			value = relative(value.(string))
 		}
 		if key == titleKey {
 			title = fmt.Sprintf("%s", value)
@@ -396,3 +502,17 @@ func truncateHeight(content string, height int) string {
 	}
 	return content
 }
+
+func relative(path string) string {
+	return strings.TrimPrefix(path, app.Info.Path.Root+"/")
+}
+
+func extension(path string) string {
+	ext := filepath.Ext(path)
+	if ext == "" {
+		ext = ""
+	} else {
+		ext = strings.ToLower(ext[1:])
+	}
+	return ext
+}

+ 252 - 141
packages/tui/internal/components/chat/messages.go

@@ -1,7 +1,7 @@
 package chat
 
 import (
-	"fmt"
+	"strings"
 	"time"
 
 	"github.com/charmbracelet/bubbles/key"
@@ -11,21 +11,23 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"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 messagesCmp struct {
-	app              *app.App
-	width, height    int
-	viewport         viewport.Model
-	spinner          spinner.Model
-	rendering        bool
-	attachments      viewport.Model
-	showToolMessages bool
-	cache            *MessageCache
+type messagesComponent struct {
+	app             *app.App
+	width, height   int
+	viewport        viewport.Model
+	spinner         spinner.Model
+	rendering       bool
+	attachments     viewport.Model
+	showToolResults bool
+	cache           *MessageCache
+	tail            bool
 }
 type renderFinishedMsg struct{}
 type ToggleToolMessagesMsg struct{}
@@ -56,44 +58,54 @@ var messageKeys = MessageKeys{
 	),
 }
 
-func (m *messagesCmp) Init() tea.Cmd {
+func (m *messagesComponent) Init() tea.Cmd {
 	return tea.Batch(m.viewport.Init(), m.spinner.Tick)
 }
 
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
+	case SendMsg:
+		m.viewport.GotoBottom()
+		m.tail = true
+		return m, nil
 	case dialog.ThemeChangedMsg:
 		m.cache.Clear()
 		m.renderView()
 		return m, nil
 	case ToggleToolMessagesMsg:
-		m.showToolMessages = !m.showToolMessages
+		m.showToolResults = !m.showToolResults
 		m.renderView()
 		return m, nil
 	case state.SessionSelectedMsg:
-		// Clear cache when switching sessions
 		m.cache.Clear()
 		cmd := m.Reload()
+		m.viewport.GotoBottom()
 		return m, cmd
 	case state.SessionClearedMsg:
-		// Clear cache when session is cleared
 		m.cache.Clear()
 		cmd := m.Reload()
 		return m, cmd
 	case tea.KeyMsg:
-		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
-			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
+		if key.Matches(msg, messageKeys.PageUp) ||
+			key.Matches(msg, messageKeys.PageDown) ||
+			key.Matches(msg, messageKeys.HalfPageUp) ||
+			key.Matches(msg, messageKeys.HalfPageDown) {
 			u, cmd := m.viewport.Update(msg)
 			m.viewport = u
+			m.tail = m.viewport.AtBottom()
 			cmds = append(cmds, cmd)
 		}
 	case renderFinishedMsg:
 		m.rendering = false
-		m.viewport.GotoBottom()
+		if m.tail {
+			m.viewport.GotoBottom()
+		}
 	case state.StateUpdatedMsg:
 		m.renderView()
-		m.viewport.GotoBottom()
+		if m.tail {
+			m.viewport.GotoBottom()
+		}
 	}
 
 	spinner, cmd := m.spinner.Update(msg)
@@ -102,91 +114,159 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *messagesCmp) renderView() {
+type blockType int
+
+const (
+	none blockType = iota
+	systemTextBlock
+	userTextBlock
+	assistantTextBlock
+	toolInvocationBlock
+)
+
+func (m *messagesComponent) renderView() {
 	if m.width == 0 {
 		return
 	}
 
-	messages := make([]string, 0)
-	for _, msg := range m.app.Messages {
+	blocks := make([]string, 0)
+	previousBlockType := none
+	for _, message := range m.app.Messages {
+		if message.Role == client.System {
+			continue // ignoring system messages for now
+		}
+
 		var content string
 		var cached bool
 
-		switch msg.Role {
+		author := ""
+		switch message.Role {
 		case client.User:
-			content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
-			if !cached {
-				content = renderUserMessage(m.app.Info.User, msg, m.width)
-				m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
-			}
-			messages = append(messages, content+"\n")
+			author = app.Info.User
 		case client.Assistant:
-			content, cached = m.cache.Get(msg, m.width, m.showToolMessages, *m.app.Info)
-			if !cached {
-				content = renderAssistantMessage(msg, m.width, m.showToolMessages, *m.app.Info)
-				m.cache.Set(msg, m.width, m.showToolMessages, *m.app.Info, content)
+			author = message.Metadata.Assistant.ModelID
+		}
+
+		for _, p := range message.Parts {
+			part, err := p.ValueByDiscriminator()
+			if err != nil {
+				continue //TODO: handle error?
+			}
+
+			switch part.(type) {
+			// case client.MessagePartStepStart:
+			// 	messages = append(messages, "")
+			case client.MessagePartText:
+				text := part.(client.MessagePartText)
+				key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
+				content, cached = m.cache.Get(key)
+				if !cached {
+					content = renderText(message, text.Text, author)
+					m.cache.Set(key, content)
+				}
+				if previousBlockType != none {
+					blocks = append(blocks, "")
+				}
+				blocks = append(blocks, content)
+				if message.Role == client.User {
+					previousBlockType = userTextBlock
+				} else if message.Role == client.Assistant {
+					previousBlockType = assistantTextBlock
+				} else if message.Role == client.System {
+					previousBlockType = systemTextBlock
+				}
+			case client.MessagePartToolInvocation:
+				toolInvocationPart := part.(client.MessagePartToolInvocation)
+				toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
+				metadata := map[string]any{}
+				if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
+					metadata = message.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
+				}
+				var result *string
+				resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
+				if resultError == nil {
+					result = &resultPart.Result
+				}
+
+				if toolCall.State == "result" {
+					key := m.cache.GenerateKey(message.Id,
+						toolCall.ToolCallId,
+						m.showToolResults,
+						layout.Current.Viewport.Width,
+					)
+					content, cached = m.cache.Get(key)
+					if !cached {
+						content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
+						m.cache.Set(key, content)
+					}
+				} else {
+					// if the tool call isn't finished, never cache
+					content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
+				}
+
+				if previousBlockType != toolInvocationBlock {
+					blocks = append(blocks, "")
+				}
+				blocks = append(blocks, content)
+				previousBlockType = toolInvocationBlock
 			}
-			messages = append(messages, content+"\n")
 		}
 	}
 
-	m.viewport.SetContent(
-		styles.BaseStyle().
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					messages...,
-				),
-			),
-	)
-}
+	t := theme.CurrentTheme()
+	centered := []string{}
+	for _, block := range blocks {
+		centered = append(centered, lipgloss.PlaceHorizontal(
+			m.width,
+			lipgloss.Center,
+			block,
+			lipgloss.WithWhitespaceBackground(t.Background()),
+		))
+	}
 
-func (m *messagesCmp) View() string {
-	baseStyle := styles.BaseStyle()
+	m.viewport.Height = m.height - lipgloss.Height(m.header())
+	m.viewport.SetContent(strings.Join(centered, "\n"))
+}
 
-	if m.rendering {
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					"Loading...",
-					m.working(),
-					m.help(),
-				),
-			)
+func (m *messagesComponent) header() string {
+	if m.app.Session.Id == "" {
+		return ""
 	}
 
-	if len(m.app.Messages) == 0 {
-		content := baseStyle.
-			Width(m.width).
-			Height(m.height - 1).
-			Render(
-				m.initialScreen(),
-			)
-
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					content,
-					"",
-					m.help(),
-				),
-			)
+	t := theme.CurrentTheme()
+	width := layout.Current.Container.Width
+	base := styles.BaseStyle().Render
+	muted := styles.Muted().Render
+	headerLines := []string{}
+	headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width))
+	if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
+		headerLines = append(headerLines, muted(m.app.Session.Share.Url))
+	} else {
+		headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
 	}
+	header := strings.Join(headerLines, "\n")
+
+	header = styles.BaseStyle().
+		Width(width).
+		PaddingTop(1).
+		BorderBottom(true).
+		BorderForeground(t.BorderSubtle()).
+		BorderStyle(lipgloss.NormalBorder()).
+		Background(t.Background()).
+		Render(header)
+
+	return styles.ForceReplaceBackgroundWithLipgloss(header, t.Background())
+}
 
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				m.viewport.View(),
-				m.working(),
-				m.help(),
-			),
-		)
+func (m *messagesComponent) View() string {
+	if len(m.app.Messages) == 0 || m.rendering {
+		return m.home()
+	}
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
+		m.viewport.View(),
+	)
 }
 
 // func hasToolsWithoutResponse(messages []message.Message) bool {
@@ -225,36 +305,7 @@ func (m *messagesCmp) View() string {
 // 	return false
 // }
 
-func (m *messagesCmp) working() string {
-	text := ""
-	if len(m.app.Messages) > 0 {
-		t := theme.CurrentTheme()
-		baseStyle := styles.BaseStyle()
-
-		task := ""
-		if m.app.IsBusy() {
-			task = "Working..."
-		}
-		// lastMessage := m.app.Messages[len(m.app.Messages)-1]
-		// if hasToolsWithoutResponse(m.app.Messages) {
-		// 	task = "Waiting for tool response..."
-		// } else if hasUnfinishedToolCalls(m.app.Messages) {
-		// 	task = "Building tool call..."
-		// } else if !lastMessage.IsFinished() {
-		// 	task = "Generating..."
-		// }
-		if task != "" {
-			text += baseStyle.
-				Width(m.width).
-				Foreground(t.Primary()).
-				Bold(true).
-				Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
-		}
-	}
-	return text
-}
-
-func (m *messagesCmp) help() string {
+func (m *messagesComponent) help() string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
@@ -275,11 +326,7 @@ func (m *messagesCmp) help() string {
 			baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
 			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
 			baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
-			baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
-			baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
+			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline"),
 		)
 	}
 	return baseStyle.
@@ -287,20 +334,83 @@ func (m *messagesCmp) help() string {
 		Render(text)
 }
 
-func (m *messagesCmp) initialScreen() string {
+func (m *messagesComponent) home() string {
+	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
+	base := baseStyle.Render
+	muted := styles.Muted().Render
+
+	// 	mark := `
+	// ███▀▀█
+	// ███  █
+	// ▀▀▀▀▀▀  `
+	open := `
+█▀▀█ █▀▀█ █▀▀ █▀▀▄ 
+█░░█ █░░█ █▀▀ █░░█ 
+▀▀▀▀ █▀▀▀ ▀▀▀ ▀  ▀ `
+	code := `
+█▀▀ █▀▀█ █▀▀▄ █▀▀
+█░░ █░░█ █░░█ █▀▀
+▀▀▀ ▀▀▀▀ ▀▀▀  ▀▀▀`
+
+	logo := lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
+		styles.Muted().Render(open),
+		styles.BaseStyle().Render(code),
+	)
+	cwd := app.Info.Path.Cwd
+	config := app.Info.Path.Config
+
+	commands := [][]string{
+		{"/help", "show help"},
+		{"/sessions", "list sessions"},
+		{"/new", "start a new session"},
+		{"/model", "switch model"},
+		{"/share", "share the current session"},
+		{"/exit", "exit the app"},
+	}
+
+	commandLines := []string{}
+	for _, command := range commands {
+		commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
+	}
+
+	logoAndVersion := lipgloss.JoinVertical(
+		lipgloss.Right,
+		logo,
+		muted(app.Info.Version),
+	)
 
-	return baseStyle.Width(m.width).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Top,
-			header(m.app, m.width),
-			"",
-			lspsConfigured(m.width),
-		),
+	lines := []string{}
+	lines = append(lines, "")
+	lines = append(lines, "")
+	lines = append(lines, logoAndVersion)
+	lines = append(lines, "")
+	lines = append(lines, base("cwd ")+muted(cwd))
+	lines = append(lines, base("config ")+muted(config))
+	lines = append(lines, "")
+	lines = append(lines, commandLines...)
+	lines = append(lines, "")
+	if m.rendering {
+		lines = append(lines, styles.Muted().Render("Loading session..."))
+	} else {
+		lines = append(lines, "")
+	}
+
+	return styles.ForceReplaceBackgroundWithLipgloss(
+		lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
+			baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
+				lipgloss.JoinVertical(
+					lipgloss.Top,
+					lines...,
+				),
+			)),
+		t.Background(),
 	)
 }
 
-func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
 	if m.width == width && m.height == height {
 		return nil
 	}
@@ -311,18 +421,18 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
 	m.width = width
 	m.height = height
 	m.viewport.Width = width
-	m.viewport.Height = height - 2
+	m.viewport.Height = height - lipgloss.Height(m.header())
 	m.attachments.Width = width + 40
 	m.attachments.Height = 3
 	m.renderView()
 	return nil
 }
 
-func (m *messagesCmp) GetSize() (int, int) {
+func (m *messagesComponent) GetSize() (int, int) {
 	return m.width, m.height
 }
 
-func (m *messagesCmp) Reload() tea.Cmd {
+func (m *messagesComponent) Reload() tea.Cmd {
 	m.rendering = true
 	return func() tea.Msg {
 		m.renderView()
@@ -330,7 +440,7 @@ func (m *messagesCmp) Reload() tea.Cmd {
 	}
 }
 
-func (m *messagesCmp) BindingKeys() []key.Binding {
+func (m *messagesComponent) BindingKeys() []key.Binding {
 	return []key.Binding{
 		m.viewport.KeyMap.PageDown,
 		m.viewport.KeyMap.PageUp,
@@ -339,7 +449,7 @@ func (m *messagesCmp) BindingKeys() []key.Binding {
 	}
 }
 
-func NewMessagesCmp(app *app.App) tea.Model {
+func NewMessagesComponent(app *app.App) tea.Model {
 	customSpinner := spinner.Spinner{
 		Frames: []string{" ", "┃", "┃"},
 		FPS:    time.Second / 3,
@@ -353,12 +463,13 @@ func NewMessagesCmp(app *app.App) tea.Model {
 	vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
 	vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
 
-	return &messagesCmp{
-		app:              app,
-		viewport:         vp,
-		spinner:          s,
-		attachments:      attachments,
-		showToolMessages: true,
-		cache:            NewMessageCache(),
+	return &messagesComponent{
+		app:             app,
+		viewport:        vp,
+		spinner:         s,
+		attachments:     attachments,
+		showToolResults: true,
+		cache:           NewMessageCache(),
+		tail:            true,
 	}
 }

+ 0 - 212
packages/tui/internal/components/chat/sidebar.go

@@ -1,212 +0,0 @@
-package chat
-
-import (
-	"fmt"
-	"sort"
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/state"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
-)
-
-type sidebarCmp struct {
-	app           *app.App
-	width, height int
-	modFiles      map[string]struct {
-		additions int
-		removals  int
-	}
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
-	// TODO: History service not implemented in API yet
-	// Initialize the modified files map
-	m.modFiles = make(map[string]struct {
-		additions int
-		removals  int
-	})
-	return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg.(type) {
-	case state.SessionSelectedMsg:
-		// TODO: History service not implemented in API yet
-		// ctx := context.Background()
-		// m.loadModifiedFiles(ctx)
-		// case pubsub.Event[history.File]:
-		// TODO: History service not implemented in API yet
-		// if msg.Payload.SessionID == m.app.CurrentSession.ID {
-		// 	// Process the individual file change instead of reloading all files
-		// 	ctx := context.Background()
-		// 	m.processFileChanges(ctx, msg.Payload)
-		// }
-	}
-	return m, nil
-}
-
-func (m *sidebarCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-	shareUrl := ""
-	if m.app.Session.Share != nil {
-		shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
-	}
-
-	// qrcode := ""
-	// if m.app.Session.ShareID != nil {
-	// 	url := "https://dev.opencode.ai/share?id="
-	// 	qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
-	// }
-
-	return baseStyle.
-		Width(m.width).
-		PaddingLeft(4).
-		PaddingRight(1).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				header(m.app, m.width),
-				" ",
-				m.sessionSection(),
-				shareUrl,
-			),
-		)
-}
-
-func (m *sidebarCmp) sessionSection() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	sessionKey := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Render("Session")
-
-	sessionValue := baseStyle.
-		Foreground(t.Text()).
-		Render(fmt.Sprintf(": %s", m.app.Session.Title))
-
-	return sessionKey + sessionValue
-}
-
-func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	stats := ""
-	if additions > 0 && removals > 0 {
-		additionsStr := baseStyle.
-			Foreground(t.Success()).
-			PaddingLeft(1).
-			Render(fmt.Sprintf("+%d", additions))
-
-		removalsStr := baseStyle.
-			Foreground(t.Error()).
-			PaddingLeft(1).
-			Render(fmt.Sprintf("-%d", removals))
-
-		content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
-		stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
-	} else if additions > 0 {
-		additionsStr := fmt.Sprintf(" %s", baseStyle.
-			PaddingLeft(1).
-			Foreground(t.Success()).
-			Render(fmt.Sprintf("+%d", additions)))
-		stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
-	} else if removals > 0 {
-		removalsStr := fmt.Sprintf(" %s", baseStyle.
-			PaddingLeft(1).
-			Foreground(t.Error()).
-			Render(fmt.Sprintf("-%d", removals)))
-		stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
-	}
-
-	filePathStr := baseStyle.Render(filePath)
-
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				filePathStr,
-				stats,
-			),
-		)
-}
-
-func (m *sidebarCmp) modifiedFiles() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	modifiedFiles := baseStyle.
-		Width(m.width).
-		Foreground(t.Primary()).
-		Bold(true).
-		Render("Modified Files:")
-
-	// If no modified files, show a placeholder message
-	if m.modFiles == nil || len(m.modFiles) == 0 {
-		message := "No modified files"
-		remainingWidth := m.width - lipgloss.Width(message)
-		if remainingWidth > 0 {
-			message += strings.Repeat(" ", remainingWidth)
-		}
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					modifiedFiles,
-					baseStyle.Foreground(t.TextMuted()).Render(message),
-				),
-			)
-	}
-
-	// Sort file paths alphabetically for consistent ordering
-	var paths []string
-	for path := range m.modFiles {
-		paths = append(paths, path)
-	}
-	sort.Strings(paths)
-
-	// Create views for each file in sorted order
-	var fileViews []string
-	for _, path := range paths {
-		stats := m.modFiles[path]
-		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
-	}
-
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				modifiedFiles,
-				lipgloss.JoinVertical(
-					lipgloss.Left,
-					fileViews...,
-				),
-			),
-		)
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
-	m.width = width
-	m.height = height
-	return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func NewSidebarCmp(app *app.App) tea.Model {
-	return &sidebarCmp{
-		app: app,
-	}
-}

+ 91 - 81
packages/tui/internal/components/core/status.go

@@ -98,16 +98,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget() string {
+func logo() string {
 	t := theme.CurrentTheme()
-	helpText := "ctrl+? help"
-
-	return styles.Padded().
-		Background(t.TextMuted()).
-		Foreground(t.BackgroundDarker()).
-		Bold(true).
-		Render(helpText)
+	mark := styles.Bold().Foreground(t.Primary()).Render("◧ ")
+	open := styles.Muted().Render("open")
+	code := styles.BaseStyle().Bold(true).Render("code")
+	version := styles.Muted().Render(app.Info.Version)
+	return styles.ForceReplaceBackgroundWithLipgloss(
+		styles.Padded().Render(mark+open+code+" "+version),
+		t.BackgroundElement(),
+	)
 }
 
 func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
@@ -132,16 +132,28 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
 
 	// Format cost with $ symbol and 2 decimal places
 	formattedCost := fmt.Sprintf("$%.2f", cost)
-
 	percentage := (float64(tokens) / float64(contextWindow)) * 100
 
 	return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
 }
 
 func (m statusCmp) View() string {
+	if m.app.Session.Id == "" {
+		return styles.BaseStyle().
+			Width(m.width).
+			Height(2).
+			Render("")
+	}
+
 	t := theme.CurrentTheme()
-	status := getHelpWidget()
+	logo := logo()
 
+	cwd := styles.Padded().
+		Foreground(t.TextMuted()).
+		Background(t.BackgroundSubtle()).
+		Render(app.Info.Path.Cwd)
+
+	sessionInfo := ""
 	if m.app.Session.Id != "" {
 		tokens := float32(0)
 		cost := float32(0)
@@ -157,87 +169,85 @@ func (m statusCmp) View() string {
 			}
 		}
 
-		tokensInfo := styles.Padded().
-			Background(t.Text()).
-			Foreground(t.BackgroundSecondary()).
+		sessionInfo = styles.Padded().
+			Background(t.BackgroundElement()).
+			Foreground(t.TextMuted()).
 			Render(formatTokensAndCost(tokens, contextWindow, cost))
-		status += tokensInfo
 	}
 
-	diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
-
-	modelName := m.model()
+	// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
 
-	statusWidth := max(
+	space := max(
 		0,
-		m.width-
-			lipgloss.Width(status)-
-			lipgloss.Width(modelName)-
-			lipgloss.Width(diagnostics),
+		m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
 	)
+	spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
 
-	const minInlineWidth = 30
+	status := logo + cwd + spacer + sessionInfo
 
-	// Display the first status message if available
-	var statusMessage string
-	if len(m.queue) > 0 {
-		sm := m.queue[0]
-		infoStyle := styles.Padded().
-			Foreground(t.Background())
-
-		switch sm.Level {
-		case "info":
-			infoStyle = infoStyle.Background(t.Info())
-		case "warn":
-			infoStyle = infoStyle.Background(t.Warning())
-		case "error":
-			infoStyle = infoStyle.Background(t.Error())
-		case "debug":
-			infoStyle = infoStyle.Background(t.TextMuted())
-		}
+	blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+	return blank + "\n" + status
 
-		// Truncate message if it's longer than available width
-		msg := sm.Message
-		availWidth := statusWidth - 10
-
-		// If we have enough space, show inline
-		if availWidth >= minInlineWidth {
-			if len(msg) > availWidth && availWidth > 0 {
-				msg = msg[:availWidth] + "..."
-			}
-			status += infoStyle.Width(statusWidth).Render(msg)
-		} else {
-			// Otherwise, prepare a full-width message to show above
-			if len(msg) > m.width-10 && m.width > 10 {
-				msg = msg[:m.width-10] + "..."
-			}
-			statusMessage = infoStyle.Width(m.width).Render(msg)
-
-			// Add empty space in the status bar
-			status += styles.Padded().
-				Foreground(t.Text()).
-				Background(t.BackgroundSecondary()).
-				Width(statusWidth).
-				Render("")
-		}
-	} else {
-		status += styles.Padded().
-			Foreground(t.Text()).
-			Background(t.BackgroundSecondary()).
-			Width(statusWidth).
-			Render("")
-	}
+	// Display the first status message if available
+	// var statusMessage string
+	// if len(m.queue) > 0 {
+	// 	sm := m.queue[0]
+	// 	infoStyle := styles.Padded().
+	// 		Foreground(t.Background())
+	//
+	// 	switch sm.Level {
+	// 	case "info":
+	// 		infoStyle = infoStyle.Background(t.Info())
+	// 	case "warn":
+	// 		infoStyle = infoStyle.Background(t.Warning())
+	// 	case "error":
+	// 		infoStyle = infoStyle.Background(t.Error())
+	// 	case "debug":
+	// 		infoStyle = infoStyle.Background(t.TextMuted())
+	// 	}
+	//
+	// 	// Truncate message if it's longer than available width
+	// 	msg := sm.Message
+	// 	availWidth := statusWidth - 10
+	//
+	// 	// If we have enough space, show inline
+	// 	if availWidth >= minInlineWidth {
+	// 		if len(msg) > availWidth && availWidth > 0 {
+	// 			msg = msg[:availWidth] + "..."
+	// 		}
+	// 		status += infoStyle.Width(statusWidth).Render(msg)
+	// 	} else {
+	// 		// Otherwise, prepare a full-width message to show above
+	// 		if len(msg) > m.width-10 && m.width > 10 {
+	// 			msg = msg[:m.width-10] + "..."
+	// 		}
+	// 		statusMessage = infoStyle.Width(m.width).Render(msg)
+	//
+	// 		// Add empty space in the status bar
+	// 		status += styles.Padded().
+	// 			Foreground(t.Text()).
+	// 			Background(t.BackgroundSubtle()).
+	// 			Width(statusWidth).
+	// 			Render("")
+	// 	}
+	// } else {
+	// 	status += styles.Padded().
+	// 		Foreground(t.Text()).
+	// 		Background(t.BackgroundSubtle()).
+	// 		Width(statusWidth).
+	// 		Render("")
+	// }
 
-	status += diagnostics
-	status += modelName
+	// status += diagnostics
+	// status += modelName
 
 	// If we have a separate status message, prepend it
-	if statusMessage != "" {
-		return statusMessage + "\n" + status
-	} else {
-		blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
-		return blank + "\n" + status
-	}
+	// if statusMessage != "" {
+	// 	return statusMessage + "\n" + status
+	// } else {
+	// blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
+	// return blank + "\n" + status
+	// }
 }
 
 func (m *statusCmp) projectDiagnostics() string {
@@ -281,7 +291,7 @@ func (m *statusCmp) projectDiagnostics() string {
 	// }
 	return styles.ForceReplaceBackgroundWithLipgloss(
 		styles.Padded().Render("No diagnostics"),
-		t.BackgroundDarker(),
+		t.BackgroundElement(),
 	)
 
 	// if len(errorDiagnostics) == 0 &&

+ 1 - 1
packages/tui/internal/components/dialog/custom_commands.go

@@ -22,7 +22,7 @@ const (
 var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
 
 // LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
-func LoadCustomCommands(app *app.App) ([]Command, error) {
+func LoadCustomCommands() ([]Command, error) {
 	var commands []Command
 
 	homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")

+ 17 - 18
packages/tui/internal/components/diff/diff.go

@@ -404,10 +404,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
 	<entry type="TextWhitespace" style="%s"/>
 </style>
 `,
-		getColor(t.Background()), // Background
-		getColor(t.Text()),       // Text
-		getColor(t.Text()),       // Other
-		getColor(t.Error()),      // Error
+		getColor(t.BackgroundSubtle()), // Background
+		getColor(t.Text()),             // Text
+		getColor(t.Text()),             // Other
+		getColor(t.Error()),            // Error
 
 		getColor(t.SyntaxKeyword()), // Keyword
 		getColor(t.SyntaxKeyword()), // KeywordConstant
@@ -531,8 +531,7 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
 	removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
 	addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
 	contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
-	lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
-
+	lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
 	return
 }
 
@@ -581,7 +580,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 
 	// Get the appropriate color based on terminal background
 	bgColor := lipgloss.Color(getColor(highlightBg))
-	fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
+	fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
 
 	for i := 0; i < len(content); {
 		// Check if we're at an ANSI sequence
@@ -794,24 +793,24 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
 }
 
 // FormatDiff creates a side-by-side formatted view of a diff
-func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
-	t := theme.CurrentTheme()
+func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
+	// t := theme.CurrentTheme()
 	diffResult, err := ParseUnifiedDiff(diffText)
 	if err != nil {
 		return "", err
 	}
 
 	var sb strings.Builder
-	config := NewSideBySideConfig(opts...)
+	// config := NewSideBySideConfig(opts...)
 	for _, h := range diffResult.Hunks {
-		sb.WriteString(
-			lipgloss.NewStyle().
-				Background(t.DiffHunkHeader()).
-				Foreground(t.Background()).
-				Width(config.TotalWidth).
-				Render(h.Header) + "\n",
-		)
-		sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
+		// sb.WriteString(
+		// 	lipgloss.NewStyle().
+		// 		Background(t.DiffHunkHeader()).
+		// 		Foreground(t.Background()).
+		// 		Width(config.TotalWidth).
+		// 		Render(h.Header) + "\n",
+		// )
+		sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
 	}
 
 	return sb.String(), nil

+ 0 - 127
packages/tui/internal/components/spinner/spinner.go

@@ -1,127 +0,0 @@
-package spinner
-
-import (
-	"context"
-	"fmt"
-	"os"
-
-	"github.com/charmbracelet/bubbles/spinner"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-)
-
-// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
-type Spinner struct {
-	model  spinner.Model
-	done   chan struct{}
-	prog   *tea.Program
-	ctx    context.Context
-	cancel context.CancelFunc
-}
-
-// spinnerModel is the tea.Model for the spinner
-type spinnerModel struct {
-	spinner  spinner.Model
-	message  string
-	quitting bool
-}
-
-func (m spinnerModel) Init() tea.Cmd {
-	return m.spinner.Tick
-}
-
-func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		m.quitting = true
-		return m, tea.Quit
-	case spinner.TickMsg:
-		var cmd tea.Cmd
-		m.spinner, cmd = m.spinner.Update(msg)
-		return m, cmd
-	case quitMsg:
-		m.quitting = true
-		return m, tea.Quit
-	default:
-		return m, nil
-	}
-}
-
-func (m spinnerModel) View() string {
-	if m.quitting {
-		return ""
-	}
-	return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
-}
-
-// quitMsg is sent when we want to quit the spinner
-type quitMsg struct{}
-
-// NewSpinner creates a new spinner with the given message
-func NewSpinner(message string) *Spinner {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = s.Style.Foreground(s.Style.GetForeground())
-
-	ctx, cancel := context.WithCancel(context.Background())
-
-	model := spinnerModel{
-		spinner: s,
-		message: message,
-	}
-
-	prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
-	return &Spinner{
-		model:  s,
-		done:   make(chan struct{}),
-		prog:   prog,
-		ctx:    ctx,
-		cancel: cancel,
-	}
-}
-
-// NewThemedSpinner creates a new spinner with the given message and color
-func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = s.Style.Foreground(color)
-
-	ctx, cancel := context.WithCancel(context.Background())
-
-	model := spinnerModel{
-		spinner: s,
-		message: message,
-	}
-
-	prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
-	return &Spinner{
-		model:  s,
-		done:   make(chan struct{}),
-		prog:   prog,
-		ctx:    ctx,
-		cancel: cancel,
-	}
-}
-
-// Start begins the spinner animation
-func (s *Spinner) Start() {
-	go func() {
-		defer close(s.done)
-		go func() {
-			<-s.ctx.Done()
-			s.prog.Send(quitMsg{})
-		}()
-		_, err := s.prog.Run()
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
-		}
-	}()
-}
-
-// Stop ends the spinner animation
-func (s *Spinner) Stop() {
-	s.cancel()
-	<-s.done
-}

+ 0 - 24
packages/tui/internal/components/spinner/spinner_test.go

@@ -1,24 +0,0 @@
-package spinner
-
-import (
-	"testing"
-	"time"
-)
-
-func TestSpinner(t *testing.T) {
-	t.Parallel()
-
-	// Create a spinner
-	s := NewSpinner("Test spinner")
-	
-	// Start the spinner
-	s.Start()
-	
-	// Wait a bit to let it run
-	time.Sleep(100 * time.Millisecond)
-	
-	// Stop the spinner
-	s.Stop()
-	
-	// If we got here without panicking, the test passes
-}

+ 50 - 2
packages/tui/internal/layout/container.go

@@ -13,6 +13,8 @@ type Container interface {
 	Bindings
 	Focus()
 	Blur()
+	MaxWidth() int
+	Alignment() lipgloss.Position
 }
 
 type container struct {
@@ -32,6 +34,9 @@ type container struct {
 	borderLeft   bool
 	borderStyle  lipgloss.Border
 
+	maxWidth int
+	align    lipgloss.Position
+
 	focused bool
 }
 
@@ -51,6 +56,11 @@ func (c *container) View() string {
 	width := c.width
 	height := c.height
 
+	// Apply max width constraint if set
+	if c.maxWidth > 0 && width > c.maxWidth {
+		width = c.maxWidth
+	}
+
 	style = style.Background(t.Background())
 
 	// Apply border if any side is enabled
@@ -74,7 +84,7 @@ func (c *container) View() string {
 		if c.focused {
 			style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
 		} else {
-			style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
+			style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
 		}
 	}
 	style = style.
@@ -92,6 +102,12 @@ func (c *container) SetSize(width, height int) tea.Cmd {
 	c.width = width
 	c.height = height
 
+	// Apply max width constraint if set
+	effectiveWidth := width
+	if c.maxWidth > 0 && width > c.maxWidth {
+		effectiveWidth = c.maxWidth
+	}
+
 	// If the content implements Sizeable, adjust its size to account for padding and borders
 	if sizeable, ok := c.content.(Sizeable); ok {
 		// Calculate horizontal space taken by padding and borders
@@ -113,7 +129,7 @@ func (c *container) SetSize(width, height int) tea.Cmd {
 		}
 
 		// Set content size with adjusted dimensions
-		contentWidth := max(0, width-horizontalSpace)
+		contentWidth := max(0, effectiveWidth-horizontalSpace)
 		contentHeight := max(0, height-verticalSpace)
 		return sizeable.SetSize(contentWidth, contentHeight)
 	}
@@ -124,6 +140,14 @@ func (c *container) GetSize() (int, int) {
 	return c.width, c.height
 }
 
+func (c *container) MaxWidth() int {
+	return c.maxWidth
+}
+
+func (c *container) Alignment() lipgloss.Position {
+	return c.align
+}
+
 func (c *container) BindingKeys() []key.Binding {
 	if b, ok := c.content.(Bindings); ok {
 		return b.BindingKeys()
@@ -228,3 +252,27 @@ func WithThickBorder() ContainerOption {
 func WithDoubleBorder() ContainerOption {
 	return WithBorderStyle(lipgloss.DoubleBorder())
 }
+
+func WithMaxWidth(maxWidth int) ContainerOption {
+	return func(c *container) {
+		c.maxWidth = maxWidth
+	}
+}
+
+func WithAlign(align lipgloss.Position) ContainerOption {
+	return func(c *container) {
+		c.align = align
+	}
+}
+
+func WithAlignLeft() ContainerOption {
+	return WithAlign(lipgloss.Left)
+}
+
+func WithAlignCenter() ContainerOption {
+	return WithAlign(lipgloss.Center)
+}
+
+func WithAlignRight() ContainerOption {
+	return WithAlign(lipgloss.Right)
+}

+ 248 - 0
packages/tui/internal/layout/flex.go

@@ -0,0 +1,248 @@
+package layout
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/sst/opencode/internal/theme"
+)
+
+type FlexDirection int
+
+const (
+	FlexDirectionHorizontal FlexDirection = iota
+	FlexDirectionVertical
+)
+
+type FlexPaneSize struct {
+	Fixed bool
+	Size  int
+}
+
+var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
+
+func FlexPaneSizeFixed(size int) FlexPaneSize {
+	return FlexPaneSize{Fixed: true, Size: size}
+}
+
+type FlexLayout interface {
+	tea.Model
+	Sizeable
+	Bindings
+	SetPanes(panes []Container) tea.Cmd
+	SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
+	SetDirection(direction FlexDirection) tea.Cmd
+}
+
+type flexLayout struct {
+	width     int
+	height    int
+	direction FlexDirection
+	panes     []Container
+	sizes     []FlexPaneSize
+}
+
+type FlexLayoutOption func(*flexLayout)
+
+func (f *flexLayout) Init() tea.Cmd {
+	var cmds []tea.Cmd
+	for _, pane := range f.panes {
+		if pane != nil {
+			cmds = append(cmds, pane.Init())
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		return f, f.SetSize(msg.Width, msg.Height)
+	}
+
+	for i, pane := range f.panes {
+		if pane != nil {
+			u, cmd := pane.Update(msg)
+			f.panes[i] = u.(Container)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
+	return f, tea.Batch(cmds...)
+}
+
+func (f *flexLayout) View() string {
+	t := theme.CurrentTheme()
+
+	if len(f.panes) == 0 {
+		return ""
+	}
+
+	views := make([]string, 0, len(f.panes))
+	for i, pane := range f.panes {
+		if pane == nil {
+			continue
+		}
+
+		var paneWidth, paneHeight int
+		if f.direction == FlexDirectionHorizontal {
+			paneWidth, paneHeight = f.calculatePaneSize(i)
+			view := lipgloss.PlaceHorizontal(
+				paneWidth,
+				pane.Alignment(),
+				pane.View(),
+				lipgloss.WithWhitespaceBackground(t.Background()),
+			)
+			views = append(views, view)
+		} else {
+			paneWidth, paneHeight = f.calculatePaneSize(i)
+			view := lipgloss.Place(
+				f.width,
+				paneHeight,
+				lipgloss.Center,
+				pane.Alignment(),
+				pane.View(),
+				lipgloss.WithWhitespaceBackground(t.Background()),
+			)
+			views = append(views, view)
+		}
+	}
+
+	if f.direction == FlexDirectionHorizontal {
+		return lipgloss.JoinHorizontal(lipgloss.Center, views...)
+	}
+	return lipgloss.JoinVertical(lipgloss.Center, views...)
+}
+
+func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
+	if index >= len(f.panes) {
+		return 0, 0
+	}
+
+	totalFixed := 0
+	flexCount := 0
+
+	for i, pane := range f.panes {
+		if pane == nil {
+			continue
+		}
+		if i < len(f.sizes) && f.sizes[i].Fixed {
+			if f.direction == FlexDirectionHorizontal {
+				totalFixed += f.sizes[i].Size
+			} else {
+				totalFixed += f.sizes[i].Size
+			}
+		} else {
+			flexCount++
+		}
+	}
+
+	if f.direction == FlexDirectionHorizontal {
+		height = f.height
+		if index < len(f.sizes) && f.sizes[index].Fixed {
+			width = f.sizes[index].Size
+		} else if flexCount > 0 {
+			remainingSpace := f.width - totalFixed
+			width = remainingSpace / flexCount
+		}
+	} else {
+		width = f.width
+		if index < len(f.sizes) && f.sizes[index].Fixed {
+			height = f.sizes[index].Size
+		} else if flexCount > 0 {
+			remainingSpace := f.height - totalFixed
+			height = remainingSpace / flexCount
+		}
+	}
+
+	return width, height
+}
+
+func (f *flexLayout) SetSize(width, height int) tea.Cmd {
+	f.width = width
+	f.height = height
+
+	var cmds []tea.Cmd
+	for i, pane := range f.panes {
+		if pane != nil {
+			paneWidth, paneHeight := f.calculatePaneSize(i)
+			cmd := pane.SetSize(paneWidth, paneHeight)
+			cmds = append(cmds, cmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+func (f *flexLayout) GetSize() (int, int) {
+	return f.width, f.height
+}
+
+func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
+	f.panes = panes
+	if f.width > 0 && f.height > 0 {
+		return f.SetSize(f.width, f.height)
+	}
+	return nil
+}
+
+func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
+	f.sizes = sizes
+	if f.width > 0 && f.height > 0 {
+		return f.SetSize(f.width, f.height)
+	}
+	return nil
+}
+
+func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
+	f.direction = direction
+	if f.width > 0 && f.height > 0 {
+		return f.SetSize(f.width, f.height)
+	}
+	return nil
+}
+
+func (f *flexLayout) BindingKeys() []key.Binding {
+	keys := []key.Binding{}
+	for _, pane := range f.panes {
+		if pane != nil {
+			if b, ok := pane.(Bindings); ok {
+				keys = append(keys, b.BindingKeys()...)
+			}
+		}
+	}
+	return keys
+}
+
+func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
+	layout := &flexLayout{
+		direction: FlexDirectionHorizontal,
+		panes:     []Container{},
+		sizes:     []FlexPaneSize{},
+	}
+	for _, option := range options {
+		option(layout)
+	}
+	return layout
+}
+
+func WithDirection(direction FlexDirection) FlexLayoutOption {
+	return func(f *flexLayout) {
+		f.direction = direction
+	}
+}
+
+func WithPanes(panes ...Container) FlexLayoutOption {
+	return func(f *flexLayout) {
+		f.panes = panes
+	}
+}
+
+func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
+	return func(f *flexLayout) {
+		f.sizes = sizes
+	}
+}
+

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

@@ -7,6 +7,35 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 )
 
+var Current *LayoutInfo
+
+func init() {
+	Current = &LayoutInfo{
+		Size:      LayoutSizeNormal,
+		Viewport:  Dimensions{Width: 80, Height: 25},
+		Container: Dimensions{Width: 80, Height: 25},
+	}
+}
+
+type LayoutSize string
+
+const (
+	LayoutSizeSmall  LayoutSize = "small"
+	LayoutSizeNormal LayoutSize = "normal"
+	LayoutSizeLarge  LayoutSize = "large"
+)
+
+type Dimensions struct {
+	Width  int
+	Height int
+}
+
+type LayoutInfo struct {
+	Size      LayoutSize
+	Viewport  Dimensions
+	Container Dimensions
+}
+
 type Focusable interface {
 	Focus() tea.Cmd
 	Blur() tea.Cmd

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

@@ -17,18 +17,15 @@ import (
 // https://github.com/charmbracelet/lipgloss/pull/102
 // as well as the lipgloss library, with some modification for what I needed.
 
-// Split a string into lines, additionally returning the size of the widest
-// line.
+// Split a string into lines, additionally returning the size of the widest line.
 func getLines(s string) (lines []string, widest int) {
 	lines = strings.Split(s, "\n")
-
 	for _, l := range lines {
 		w := ansi.PrintableRuneWidth(l)
 		if widest < w {
 			widest = w
 		}
 	}
-
 	return lines, widest
 }
 
@@ -49,7 +46,7 @@ func PlaceOverlay(
 
 		var shadowbg string = ""
 		shadowchar := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
+			Background(t.BackgroundElement()).
 			Foreground(t.Background()).
 			Render("░")
 		bgchar := baseStyle.Render(" ")

+ 0 - 283
packages/tui/internal/layout/split.go

@@ -1,283 +0,0 @@
-package layout
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/sst/opencode/internal/theme"
-)
-
-type SplitPaneLayout interface {
-	tea.Model
-	Sizeable
-	Bindings
-	SetLeftPanel(panel Container) tea.Cmd
-	SetRightPanel(panel Container) tea.Cmd
-	SetBottomPanel(panel Container) tea.Cmd
-
-	ClearLeftPanel() tea.Cmd
-	ClearRightPanel() tea.Cmd
-	ClearBottomPanel() tea.Cmd
-}
-
-type splitPaneLayout struct {
-	width         int
-	height        int
-	ratio         float64
-	verticalRatio float64
-
-	rightPanel  Container
-	leftPanel   Container
-	bottomPanel Container
-}
-
-type SplitPaneOption func(*splitPaneLayout)
-
-func (s *splitPaneLayout) Init() tea.Cmd {
-	var cmds []tea.Cmd
-
-	if s.leftPanel != nil {
-		cmds = append(cmds, s.leftPanel.Init())
-	}
-
-	if s.rightPanel != nil {
-		cmds = append(cmds, s.rightPanel.Init())
-	}
-
-	if s.bottomPanel != nil {
-		cmds = append(cmds, s.bottomPanel.Init())
-	}
-
-	return tea.Batch(cmds...)
-}
-
-func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		return s, s.SetSize(msg.Width, msg.Height)
-	}
-
-	if s.rightPanel != nil {
-		u, cmd := s.rightPanel.Update(msg)
-		s.rightPanel = u.(Container)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	if s.leftPanel != nil {
-		u, cmd := s.leftPanel.Update(msg)
-		s.leftPanel = u.(Container)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	if s.bottomPanel != nil {
-		u, cmd := s.bottomPanel.Update(msg)
-		s.bottomPanel = u.(Container)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	return s, tea.Batch(cmds...)
-}
-
-func (s *splitPaneLayout) View() string {
-	var topSection string
-
-	if s.leftPanel != nil && s.rightPanel != nil {
-		leftView := s.leftPanel.View()
-		rightView := s.rightPanel.View()
-		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
-	} else if s.leftPanel != nil {
-		topSection = s.leftPanel.View()
-	} else if s.rightPanel != nil {
-		topSection = s.rightPanel.View()
-	} else {
-		topSection = ""
-	}
-
-	var finalView string
-
-	if s.bottomPanel != nil && topSection != "" {
-		bottomView := s.bottomPanel.View()
-		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
-	} else if s.bottomPanel != nil {
-		finalView = s.bottomPanel.View()
-	} else {
-		finalView = topSection
-	}
-
-	if finalView != "" {
-		t := theme.CurrentTheme()
-
-		style := lipgloss.NewStyle().
-			Width(s.width).
-			Height(s.height).
-			Background(t.Background())
-
-		return style.Render(finalView)
-	}
-
-	return finalView
-}
-
-func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
-	s.width = width
-	s.height = height
-
-	var topHeight, bottomHeight int
-	if s.bottomPanel != nil {
-		topHeight = int(float64(height) * s.verticalRatio)
-		bottomHeight = height - topHeight
-	} else {
-		topHeight = height
-		bottomHeight = 0
-	}
-
-	var leftWidth, rightWidth int
-	if s.leftPanel != nil && s.rightPanel != nil {
-		leftWidth = int(float64(width) * s.ratio)
-		rightWidth = width - leftWidth
-	} else if s.leftPanel != nil {
-		leftWidth = width
-		rightWidth = 0
-	} else if s.rightPanel != nil {
-		leftWidth = 0
-		rightWidth = width
-	}
-
-	var cmds []tea.Cmd
-	if s.leftPanel != nil {
-		cmd := s.leftPanel.SetSize(leftWidth, topHeight)
-		cmds = append(cmds, cmd)
-	}
-
-	if s.rightPanel != nil {
-		cmd := s.rightPanel.SetSize(rightWidth, topHeight)
-		cmds = append(cmds, cmd)
-	}
-
-	if s.bottomPanel != nil {
-		cmd := s.bottomPanel.SetSize(width, bottomHeight)
-		cmds = append(cmds, cmd)
-	}
-	return tea.Batch(cmds...)
-}
-
-func (s *splitPaneLayout) GetSize() (int, int) {
-	return s.width, s.height
-}
-
-func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
-	s.leftPanel = panel
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
-	s.rightPanel = panel
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
-	s.bottomPanel = panel
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
-	s.leftPanel = nil
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
-	s.rightPanel = nil
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
-	s.bottomPanel = nil
-	if s.width > 0 && s.height > 0 {
-		return s.SetSize(s.width, s.height)
-	}
-	return nil
-}
-
-func (s *splitPaneLayout) BindingKeys() []key.Binding {
-	keys := []key.Binding{}
-	if s.leftPanel != nil {
-		if b, ok := s.leftPanel.(Bindings); ok {
-			keys = append(keys, b.BindingKeys()...)
-		}
-	}
-	if s.rightPanel != nil {
-		if b, ok := s.rightPanel.(Bindings); ok {
-			keys = append(keys, b.BindingKeys()...)
-		}
-	}
-	if s.bottomPanel != nil {
-		if b, ok := s.bottomPanel.(Bindings); ok {
-			keys = append(keys, b.BindingKeys()...)
-		}
-	}
-	return keys
-}
-
-func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
-
-	layout := &splitPaneLayout{
-		ratio:         0.7,
-		verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
-	}
-	for _, option := range options {
-		option(layout)
-	}
-	return layout
-}
-
-func WithLeftPanel(panel Container) SplitPaneOption {
-	return func(s *splitPaneLayout) {
-		s.leftPanel = panel
-	}
-}
-
-func WithRightPanel(panel Container) SplitPaneOption {
-	return func(s *splitPaneLayout) {
-		s.rightPanel = panel
-	}
-}
-
-func WithRatio(ratio float64) SplitPaneOption {
-	return func(s *splitPaneLayout) {
-		s.ratio = ratio
-	}
-}
-
-func WithBottomPanel(panel Container) SplitPaneOption {
-	return func(s *splitPaneLayout) {
-		s.bottomPanel = panel
-	}
-}
-
-func WithVerticalRatio(ratio float64) SplitPaneOption {
-	return func(s *splitPaneLayout) {
-		s.verticalRatio = ratio
-	}
-}

+ 14 - 32
packages/tui/internal/page/chat.go

@@ -24,7 +24,7 @@ type chatPage struct {
 	app                  *app.App
 	editor               layout.Container
 	messages             layout.Container
-	layout               layout.SplitPaneLayout
+	layout               layout.FlexLayout
 	completionDialog     dialog.CompletionDialog
 	showCompletionDialog bool
 }
@@ -96,12 +96,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			return p, cmd
 		}
-	case state.SessionSelectedMsg:
-		cmd := p.setSidebar()
-		cmds = append(cmds, cmd)
-	case state.SessionClearedMsg:
-		cmd := p.setSidebar()
-		cmds = append(cmds, cmd)
 
 	case dialog.CompletionDialogCloseMsg:
 		p.showCompletionDialog = false
@@ -116,7 +110,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			p.app.Session = &client.SessionInfo{}
 			p.app.Messages = []client.MessageInfo{}
 			return p, tea.Batch(
-				p.clearSidebar(),
 				util.CmdHandler(state.SessionClearedMsg{}),
 			)
 		case key.Matches(msg, keyMap.Cancel):
@@ -145,30 +138,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	u, cmd := p.layout.Update(msg)
 	cmds = append(cmds, cmd)
-	p.layout = u.(layout.SplitPaneLayout)
+	p.layout = u.(layout.FlexLayout)
 	return p, tea.Batch(cmds...)
 }
 
-func (p *chatPage) setSidebar() tea.Cmd {
-	sidebarContainer := layout.NewContainer(
-		chat.NewSidebarCmp(p.app),
-		layout.WithPadding(1, 1, 1, 1),
-	)
-	return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
-}
-
-func (p *chatPage) clearSidebar() tea.Cmd {
-	return p.layout.ClearRightPanel()
-}
-
 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)
-	cmd = p.setSidebar()
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
 	return tea.Batch(cmds...)
 }
 
@@ -183,6 +160,7 @@ func (p *chatPage) GetSize() (int, int) {
 func (p *chatPage) View() string {
 	layoutView := p.layout.View()
 
+	// TODO: Fix this with our new layout
 	if p.showCompletionDialog {
 		_, layoutHeight := p.layout.GetSize()
 		editorWidth, editorHeight := p.editor.GetSize()
@@ -213,21 +191,25 @@ func NewChatPage(app *app.App) tea.Model {
 	cg := completions.NewFileAndFolderContextGroup()
 	completionDialog := dialog.NewCompletionDialogCmp(cg)
 	messagesContainer := layout.NewContainer(
-		chat.NewMessagesCmp(app),
-		layout.WithPadding(1, 1, 0, 1),
+		chat.NewMessagesComponent(app),
 	)
 	editorContainer := layout.NewContainer(
-		chat.NewEditorCmp(app),
-		layout.WithBorder(true, false, false, false),
+		chat.NewEditorComponent(app),
+		layout.WithMaxWidth(layout.Current.Container.Width),
+		layout.WithAlignCenter(),
 	)
 	return &chatPage{
 		app:              app,
 		editor:           editorContainer,
 		messages:         messagesContainer,
 		completionDialog: completionDialog,
-		layout: layout.NewSplitPane(
-			layout.WithLeftPanel(messagesContainer),
-			layout.WithBottomPanel(editorContainer),
+		layout: layout.NewFlexLayout(
+			layout.WithPanes(messagesContainer, editorContainer),
+			layout.WithDirection(layout.FlexDirectionVertical),
+			layout.WithPaneSizes(
+				layout.FlexPaneSizeGrow,
+				layout.FlexPaneSizeFixed(6),
+			),
 		),
 	}
 }

+ 33 - 28
packages/tui/internal/styles/styles.go

@@ -13,23 +13,33 @@ func BaseStyle() lipgloss.Style {
 		Foreground(t.Text())
 }
 
+func Panel() lipgloss.Style {
+	t := theme.CurrentTheme()
+	return lipgloss.NewStyle().
+		Background(t.BackgroundSubtle()).
+		Border(lipgloss.NormalBorder(), true, false, true, false).
+		BorderForeground(t.BorderSubtle()).
+		Foreground(t.Text())
+}
+
 // Regular returns a basic unstyled lipgloss.Style
 func Regular() lipgloss.Style {
 	return lipgloss.NewStyle()
 }
 
 func Muted() lipgloss.Style {
-	return lipgloss.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
+	t := theme.CurrentTheme()
+	return lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted())
 }
 
 // Bold returns a bold style
 func Bold() lipgloss.Style {
-	return Regular().Bold(true)
+	return BaseStyle().Bold(true)
 }
 
 // Padded returns a style with horizontal padding
 func Padded() lipgloss.Style {
-	return Regular().Padding(0, 1)
+	return BaseStyle().Padding(0, 1)
 }
 
 // Border returns a style with a normal border
@@ -37,7 +47,7 @@ func Border() lipgloss.Style {
 	t := theme.CurrentTheme()
 	return Regular().
 		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderNormal())
+		BorderForeground(t.Border())
 }
 
 // ThickBorder returns a style with a thick border
@@ -45,7 +55,7 @@ func ThickBorder() lipgloss.Style {
 	t := theme.CurrentTheme()
 	return Regular().
 		Border(lipgloss.ThickBorder()).
-		BorderForeground(t.BorderNormal())
+		BorderForeground(t.Border())
 }
 
 // DoubleBorder returns a style with a double border
@@ -53,7 +63,7 @@ func DoubleBorder() lipgloss.Style {
 	t := theme.CurrentTheme()
 	return Regular().
 		Border(lipgloss.DoubleBorder()).
-		BorderForeground(t.BorderNormal())
+		BorderForeground(t.Border())
 }
 
 // FocusedBorder returns a style with a border using the focused border color
@@ -61,7 +71,7 @@ func FocusedBorder() lipgloss.Style {
 	t := theme.CurrentTheme()
 	return Regular().
 		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderFocused())
+		BorderForeground(t.BorderActive())
 }
 
 // DimBorder returns a style with a border using the dim border color
@@ -69,7 +79,7 @@ func DimBorder() lipgloss.Style {
 	t := theme.CurrentTheme()
 	return Regular().
 		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderDim())
+		BorderForeground(t.BorderSubtle())
 }
 
 // PrimaryColor returns the primary color from the current theme
@@ -117,37 +127,32 @@ func TextMutedColor() lipgloss.AdaptiveColor {
 	return theme.CurrentTheme().TextMuted()
 }
 
-// TextEmphasizedColor returns the emphasized text color from the current theme
-func TextEmphasizedColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().TextEmphasized()
-}
-
 // BackgroundColor returns the background color from the current theme
 func BackgroundColor() lipgloss.AdaptiveColor {
 	return theme.CurrentTheme().Background()
 }
 
-// BackgroundSecondaryColor returns the secondary background color from the current theme
-func BackgroundSecondaryColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BackgroundSecondary()
+// BackgroundSubtleColor returns the subtle background color from the current theme
+func BackgroundSubtleColor() lipgloss.AdaptiveColor {
+	return theme.CurrentTheme().BackgroundSubtle()
 }
 
-// BackgroundDarkerColor returns the darker background color from the current theme
-func BackgroundDarkerColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BackgroundDarker()
+// BackgroundElementColor returns the darker background color from the current theme
+func BackgroundElementColor() lipgloss.AdaptiveColor {
+	return theme.CurrentTheme().BackgroundElement()
 }
 
-// BorderNormalColor returns the normal border color from the current theme
-func BorderNormalColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderNormal()
+// BorderColor returns the border color from the current theme
+func BorderColor() lipgloss.AdaptiveColor {
+	return theme.CurrentTheme().Border()
 }
 
-// BorderFocusedColor returns the focused border color from the current theme
-func BorderFocusedColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderFocused()
+// BorderActiveColor returns the active border color from the current theme
+func BorderActiveColor() lipgloss.AdaptiveColor {
+	return theme.CurrentTheme().BorderActive()
 }
 
-// BorderDimColor returns the dim border color from the current theme
-func BorderDimColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderDim()
+// BorderSubtleColor returns the subtle border color from the current theme
+func BorderSubtleColor() lipgloss.AdaptiveColor {
+	return theme.CurrentTheme().BorderSubtle()
 }

+ 5 - 9
packages/tui/internal/theme/ayu.go

@@ -92,35 +92,31 @@ func NewAyuDarkTheme() *AyuDarkTheme {
 		Dark:  darkComment,
 		Light: lightComment,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  darkBackground,
 		Light: lightBackground,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkCurrentLine,
 		Light: lightCurrentLine,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  "#0b0e14", // Darker than background
 		Light: "#ffffff", // Lighter than background
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  darkBorder,
 		Light: lightBorder,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  darkBlue,
 		Light: lightBlue,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkSelection,
 		Light: lightSelection,
 	}

+ 5 - 9
packages/tui/internal/theme/catppuccin.go

@@ -60,35 +60,31 @@ func NewCatppuccinTheme() *CatppuccinTheme {
 		Dark:  mocha.Subtext0().Hex,
 		Light: latte.Subtext0().Hex,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Lavender().Hex,
-		Light: latte.Lavender().Hex,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  "#212121", // From existing styles
 		Light: "#EEEEEE", // Light equivalent
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  "#2c2c2c", // From existing styles
 		Light: "#E0E0E0", // Light equivalent
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  "#181818", // From existing styles
 		Light: "#F5F5F5", // Light equivalent
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  "#4b4c5c", // From existing styles
 		Light: "#BDBDBD", // Light equivalent
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  mocha.Blue().Hex,
 		Light: latte.Blue().Hex,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  mocha.Surface0().Hex,
 		Light: latte.Surface0().Hex,
 	}

+ 8 - 12
packages/tui/internal/theme/dracula.go

@@ -86,35 +86,31 @@ func NewDraculaTheme() *DraculaTheme {
 		Dark:  darkComment,
 		Light: lightComment,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
 
 	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  darkBackground,
 		Light: lightBackground,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkCurrentLine,
 		Light: lightCurrentLine,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  "#21222c", // Slightly darker than background
 		Light: "#ffffff", // Slightly lighter than background
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  darkBorder,
 		Light: lightBorder,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  darkPurple,
 		Light: lightPurple,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkSelection,
 		Light: lightSelection,
 	}
@@ -133,8 +129,8 @@ func NewDraculaTheme() *DraculaTheme {
 		Light: lightComment,
 	}
 	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
+		Dark:  darkCurrentLine,
+		Light: lightCurrentLine,
 	}
 	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
 		Dark:  "#50fa7b",

+ 5 - 9
packages/tui/internal/theme/flexoki.go

@@ -94,35 +94,31 @@ func NewFlexokiTheme() *FlexokiTheme {
 		Dark:  flexokiBase700,
 		Light: flexokiBase500,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400,
-		Light: flexokiYellow600,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBlack,
 		Light: flexokiPaper,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBase950,
 		Light: flexokiBase50,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBase900,
 		Light: flexokiBase100,
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBase900,
 		Light: flexokiBase100,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBlue400,
 		Light: flexokiBlue600,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  flexokiBase850,
 		Light: flexokiBase150,
 	}

+ 5 - 9
packages/tui/internal/theme/gruvbox.go

@@ -114,35 +114,31 @@ func NewGruvboxTheme() *GruvboxTheme {
 		Dark:  gruvboxDarkFg4,
 		Light: gruvboxLightFg4,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBg0,
 		Light: gruvboxLightBg0,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBg1,
 		Light: gruvboxLightBg1,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBg0Soft,
 		Light: gruvboxLightBg0Soft,
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBg2,
 		Light: gruvboxLightBg2,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBlueBright,
 		Light: gruvboxLightBlueBright,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  gruvboxDarkBg1,
 		Light: gruvboxLightBg1,
 	}

+ 10 - 12
packages/tui/internal/theme/manager.go

@@ -157,20 +157,18 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
 			theme.TextColor = adaptiveColor
 		case "textmuted":
 			theme.TextMutedColor = adaptiveColor
-		case "textemphasized":
-			theme.TextEmphasizedColor = adaptiveColor
 		case "background":
 			theme.BackgroundColor = adaptiveColor
-		case "backgroundsecondary":
-			theme.BackgroundSecondaryColor = adaptiveColor
-		case "backgrounddarker":
-			theme.BackgroundDarkerColor = adaptiveColor
-		case "bordernormal":
-			theme.BorderNormalColor = adaptiveColor
-		case "borderfocused":
-			theme.BorderFocusedColor = adaptiveColor
-		case "borderdim":
-			theme.BorderDimColor = adaptiveColor
+		case "backgroundsubtle":
+			theme.BackgroundSubtleColor = adaptiveColor
+		case "backgroundelement":
+			theme.BackgroundElementColor = adaptiveColor
+		case "border":
+			theme.BorderColor = adaptiveColor
+		case "borderactive":
+			theme.BorderActiveColor = adaptiveColor
+		case "bordersubtle":
+			theme.BorderSubtleColor = adaptiveColor
 		case "diffadded":
 			theme.DiffAddedColor = adaptiveColor
 		case "diffremoved":

+ 5 - 9
packages/tui/internal/theme/monokai.go

@@ -85,35 +85,31 @@ func NewMonokaiProTheme() *MonokaiProTheme {
 		Dark:  darkComment,
 		Light: lightComment,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  darkBackground,
 		Light: lightBackground,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkCurrentLine,
 		Light: lightCurrentLine,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  "#221f22", // Slightly darker than background
 		Light: "#ffffff", // Slightly lighter than background
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  darkBorder,
 		Light: lightBorder,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  darkCyan,
 		Light: lightCyan,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkSelection,
 		Light: lightSelection,
 	}

+ 5 - 9
packages/tui/internal/theme/onedark.go

@@ -86,35 +86,31 @@ func NewOneDarkTheme() *OneDarkTheme {
 		Dark:  darkComment,
 		Light: lightComment,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  darkBackground,
 		Light: lightBackground,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkCurrentLine,
 		Light: lightCurrentLine,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  "#21252b", // Slightly darker than background
 		Light: "#ffffff", // Slightly lighter than background
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  darkBorder,
 		Light: lightBorder,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  darkBlue,
 		Light: lightBlue,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkSelection,
 		Light: lightSelection,
 	}

+ 76 - 56
packages/tui/internal/theme/opencode.go

@@ -12,14 +12,23 @@ type OpenCodeTheme struct {
 
 // NewOpenCodeTheme creates a new instance of the OpenCode theme.
 func NewOpenCodeTheme() *OpenCodeTheme {
-	// OpenCode color palette
-	// Dark mode colors
-	darkBackground := "#212121"
-	darkCurrentLine := "#252525"
-	darkSelection := "#303030"
-	darkForeground := "#e0e0e0"
-	darkComment := "#6a6a6a"
-	darkPrimary := "#fab283"   // Primary orange/gold
+	// OpenCode color palette with Radix-inspired scale progression
+	// Dark mode colors - using a neutral gray scale as base
+	darkStep1 := "#0a0a0a"  // App background
+	darkStep2 := "#141414"  // Subtle background
+	darkStep3 := "#1e1e1e"  // UI element background
+	darkStep4 := "#282828"  // Hovered UI element background
+	darkStep5 := "#323232"  // Active/Selected UI element background
+	darkStep6 := "#3c3c3c"  // Subtle borders and separators
+	darkStep7 := "#484848"  // UI element border and focus rings
+	darkStep8 := "#606060"  // Hovered UI element border
+	darkStep9 := "#fab283"  // Solid backgrounds (primary orange/gold)
+	darkStep10 := "#ffc09f" // Hovered solid backgrounds
+	darkStep11 := "#808080" // Low-contrast text (more muted)
+	darkStep12 := "#eeeeee" // High-contrast text
+
+	// Dark mode accent colors
+	darkPrimary := darkStep9   // Primary uses step 9 (solid background)
 	darkSecondary := "#5c9cf5" // Secondary blue
 	darkAccent := "#9d7cd8"    // Accent purple
 	darkRed := "#e06c75"       // Error red
@@ -27,15 +36,23 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 	darkGreen := "#7fd88f"     // Success green
 	darkCyan := "#56b6c2"      // Info cyan
 	darkYellow := "#e5c07b"    // Emphasized text
-	darkBorder := "#4b4c5c"    // Border color
 
-	// Light mode colors
-	lightBackground := "#f8f8f8"
-	lightCurrentLine := "#f0f0f0"
-	lightSelection := "#e5e5e6"
-	lightForeground := "#2a2a2a"
-	lightComment := "#8a8a8a"
-	lightPrimary := "#3b7dd8"   // Primary blue
+	// Light mode colors - using a neutral gray scale as base
+	lightStep1 := "#ffffff"  // App background
+	lightStep2 := "#fafafa"  // Subtle background
+	lightStep3 := "#f5f5f5"  // UI element background
+	lightStep4 := "#ebebeb"  // Hovered UI element background
+	lightStep5 := "#e1e1e1"  // Active/Selected UI element background
+	lightStep6 := "#d4d4d4"  // Subtle borders and separators
+	lightStep7 := "#b8b8b8"  // UI element border and focus rings
+	lightStep8 := "#a0a0a0"  // Hovered UI element border
+	lightStep9 := "#3b7dd8"  // Solid backgrounds (primary blue)
+	lightStep10 := "#2968c3" // Hovered solid backgrounds
+	lightStep11 := "#8a8a8a" // Low-contrast text (more muted)
+	lightStep12 := "#1a1a1a" // High-contrast text
+
+	// Light mode accent colors
+	lightPrimary := lightStep9  // Primary uses step 9 (solid background)
 	lightSecondary := "#7b5bb6" // Secondary purple
 	lightAccent := "#d68c27"    // Accent orange/gold
 	lightRed := "#d1383d"       // Error red
@@ -43,7 +60,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 	lightGreen := "#3d9a57"     // Success green
 	lightCyan := "#318795"      // Info cyan
 	lightYellow := "#b0851f"    // Emphasized text
-	lightBorder := "#d3d3d3"    // Border color
+
+	// Unused variables to avoid compiler errors (these could be used for hover states)
+	_ = darkStep4
+	_ = darkStep5
+	_ = darkStep10
+	_ = lightStep4
+	_ = lightStep5
+	_ = lightStep10
 
 	theme := &OpenCodeTheme{}
 
@@ -81,44 +105,40 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 
 	// Text colors
 	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
+		Dark:  darkStep1,
+		Light: lightStep1,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep2,
+		Light: lightStep2,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#121212", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep3,
+		Light: lightStep3,
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
+	theme.BorderColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep7,
+		Light: lightStep7,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep8,
+		Light: lightStep8,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep6,
+		Light: lightStep6,
 	}
 
 	// Diff view colors
@@ -155,12 +175,12 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 		Light: "#FFEBEE",
 	}
 	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
+		Dark:  darkStep2,
+		Light: lightStep2,
 	}
 	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#888888",
-		Light: "#9E9E9E",
+		Dark:  darkStep3,
+		Light: lightStep3,
 	}
 	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
 		Dark:  "#293229",
@@ -173,8 +193,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 
 	// Markdown colors
 	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
 		Dark:  darkSecondary,
@@ -205,8 +225,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 		Light: lightAccent,
 	}
 	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
 		Dark:  darkPrimary,
@@ -225,14 +245,14 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 		Light: lightCyan,
 	}
 	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 
 	// Syntax highlighting colors
 	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
 		Dark:  darkSecondary,
@@ -263,8 +283,8 @@ func NewOpenCodeTheme() *OpenCodeTheme {
 		Light: lightCyan,
 	}
 	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 
 	return theme

+ 39 - 42
packages/tui/internal/theme/theme.go

@@ -11,32 +11,31 @@ import (
 // All colors must be defined as lipgloss.AdaptiveColor to support
 // both light and dark terminal backgrounds.
 type Theme interface {
-	// Base colors
-	Primary() lipgloss.AdaptiveColor
+	// Background colors
+	Background() lipgloss.AdaptiveColor        // Radix 1
+	BackgroundSubtle() lipgloss.AdaptiveColor  // Radix 2
+	BackgroundElement() lipgloss.AdaptiveColor // Radix 3
+
+	// Border colors
+	BorderSubtle() lipgloss.AdaptiveColor // Radix 6
+	Border() lipgloss.AdaptiveColor       // Radix 7
+	BorderActive() lipgloss.AdaptiveColor // Radix 8
+
+	// Brand colors
+	Primary() lipgloss.AdaptiveColor // Radix 9
 	Secondary() lipgloss.AdaptiveColor
 	Accent() lipgloss.AdaptiveColor
 
+	// Text colors
+	TextMuted() lipgloss.AdaptiveColor // Radix 11
+	Text() lipgloss.AdaptiveColor      // Radix 12
+
 	// Status colors
 	Error() lipgloss.AdaptiveColor
 	Warning() lipgloss.AdaptiveColor
 	Success() lipgloss.AdaptiveColor
 	Info() lipgloss.AdaptiveColor
 
-	// Text colors
-	Text() lipgloss.AdaptiveColor
-	TextMuted() lipgloss.AdaptiveColor
-	TextEmphasized() lipgloss.AdaptiveColor
-
-	// Background colors
-	Background() lipgloss.AdaptiveColor
-	BackgroundSecondary() lipgloss.AdaptiveColor
-	BackgroundDarker() lipgloss.AdaptiveColor
-
-	// Border colors
-	BorderNormal() lipgloss.AdaptiveColor
-	BorderFocused() lipgloss.AdaptiveColor
-	BorderDim() lipgloss.AdaptiveColor
-
 	// Diff view colors
 	DiffAdded() lipgloss.AdaptiveColor
 	DiffRemoved() lipgloss.AdaptiveColor
@@ -82,32 +81,31 @@ type Theme interface {
 // BaseTheme provides a default implementation of the Theme interface
 // that can be embedded in concrete theme implementations.
 type BaseTheme struct {
-	// Base colors
+	// Background colors
+	BackgroundColor        lipgloss.AdaptiveColor
+	BackgroundSubtleColor  lipgloss.AdaptiveColor
+	BackgroundElementColor lipgloss.AdaptiveColor
+
+	// Border colors
+	BorderSubtleColor lipgloss.AdaptiveColor
+	BorderColor       lipgloss.AdaptiveColor
+	BorderActiveColor lipgloss.AdaptiveColor
+
+	// Brand colors
 	PrimaryColor   lipgloss.AdaptiveColor
 	SecondaryColor lipgloss.AdaptiveColor
 	AccentColor    lipgloss.AdaptiveColor
 
+	// Text colors
+	TextMutedColor lipgloss.AdaptiveColor
+	TextColor      lipgloss.AdaptiveColor
+
 	// Status colors
 	ErrorColor   lipgloss.AdaptiveColor
 	WarningColor lipgloss.AdaptiveColor
 	SuccessColor lipgloss.AdaptiveColor
 	InfoColor    lipgloss.AdaptiveColor
 
-	// Text colors
-	TextColor           lipgloss.AdaptiveColor
-	TextMutedColor      lipgloss.AdaptiveColor
-	TextEmphasizedColor lipgloss.AdaptiveColor
-
-	// Background colors
-	BackgroundColor          lipgloss.AdaptiveColor
-	BackgroundSecondaryColor lipgloss.AdaptiveColor
-	BackgroundDarkerColor    lipgloss.AdaptiveColor
-
-	// Border colors
-	BorderNormalColor  lipgloss.AdaptiveColor
-	BorderFocusedColor lipgloss.AdaptiveColor
-	BorderDimColor     lipgloss.AdaptiveColor
-
 	// Diff view colors
 	DiffAddedColor               lipgloss.AdaptiveColor
 	DiffRemovedColor             lipgloss.AdaptiveColor
@@ -160,17 +158,16 @@ func (t *BaseTheme) Warning() lipgloss.AdaptiveColor { return t.WarningColor }
 func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor }
 func (t *BaseTheme) Info() lipgloss.AdaptiveColor    { return t.InfoColor }
 
-func (t *BaseTheme) Text() lipgloss.AdaptiveColor           { return t.TextColor }
-func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor      { return t.TextMutedColor }
-func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor }
+func (t *BaseTheme) Text() lipgloss.AdaptiveColor      { return t.TextColor }
+func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
 
-func (t *BaseTheme) Background() lipgloss.AdaptiveColor          { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor }
-func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor    { return t.BackgroundDarkerColor }
+func (t *BaseTheme) Background() lipgloss.AdaptiveColor        { return t.BackgroundColor }
+func (t *BaseTheme) BackgroundSubtle() lipgloss.AdaptiveColor  { return t.BackgroundSubtleColor }
+func (t *BaseTheme) BackgroundElement() lipgloss.AdaptiveColor { return t.BackgroundElementColor }
 
-func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor  { return t.BorderNormalColor }
-func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor }
-func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor     { return t.BorderDimColor }
+func (t *BaseTheme) Border() lipgloss.AdaptiveColor       { return t.BorderColor }
+func (t *BaseTheme) BorderActive() lipgloss.AdaptiveColor { return t.BorderActiveColor }
+func (t *BaseTheme) BorderSubtle() lipgloss.AdaptiveColor { return t.BorderSubtleColor }
 
 func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor            { return t.DiffAddedColor }
 func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor          { return t.DiffRemovedColor }

+ 0 - 89
packages/tui/internal/theme/theme_test.go

@@ -1,89 +0,0 @@
-package theme
-
-import (
-	"testing"
-)
-
-func TestThemeRegistration(t *testing.T) {
-	// Get list of available themes
-	availableThemes := AvailableThemes()
-
-	// Check if "catppuccin" theme is registered
-	catppuccinFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "catppuccin" {
-			catppuccinFound = true
-			break
-		}
-	}
-
-	if !catppuccinFound {
-		t.Errorf("Catppuccin theme is not registered")
-	}
-
-	// Check if "gruvbox" theme is registered
-	gruvboxFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "gruvbox" {
-			gruvboxFound = true
-			break
-		}
-	}
-
-	if !gruvboxFound {
-		t.Errorf("Gruvbox theme is not registered")
-	}
-
-	// Check if "monokai" theme is registered
-	monokaiFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "monokai" {
-			monokaiFound = true
-			break
-		}
-	}
-
-	if !monokaiFound {
-		t.Errorf("Monokai theme is not registered")
-	}
-
-	// Try to get the themes and make sure they're not nil
-	catppuccin := GetTheme("catppuccin")
-	if catppuccin == nil {
-		t.Errorf("Catppuccin theme is nil")
-	}
-
-	gruvbox := GetTheme("gruvbox")
-	if gruvbox == nil {
-		t.Errorf("Gruvbox theme is nil")
-	}
-
-	monokai := GetTheme("monokai")
-	if monokai == nil {
-		t.Errorf("Monokai theme is nil")
-	}
-
-	// Test switching theme
-	originalTheme := CurrentThemeName()
-
-	err := SetTheme("gruvbox")
-	if err != nil {
-		t.Errorf("Failed to set theme to gruvbox: %v", err)
-	}
-
-	if CurrentThemeName() != "gruvbox" {
-		t.Errorf("Theme not properly switched to gruvbox")
-	}
-
-	err = SetTheme("monokai")
-	if err != nil {
-		t.Errorf("Failed to set theme to monokai: %v", err)
-	}
-
-	if CurrentThemeName() != "monokai" {
-		t.Errorf("Theme not properly switched to monokai")
-	}
-
-	// Switch back to original theme
-	_ = SetTheme(originalTheme)
-}

+ 76 - 56
packages/tui/internal/theme/tokyonight.go

@@ -12,36 +12,60 @@ type TokyoNightTheme struct {
 
 // NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
 func NewTokyoNightTheme() *TokyoNightTheme {
-	// Tokyo Night color palette
-	// Dark mode colors
-	darkBackground := "#222436"
-	darkCurrentLine := "#1e2030"
-	darkSelection := "#2f334d"
-	darkForeground := "#c8d3f5"
-	darkComment := "#636da6"
+	// Tokyo Night color palette with Radix-inspired scale progression
+	// Dark mode colors - Tokyo Night Moon variant
+	darkStep1 := "#1a1b26"  // App background (bg)
+	darkStep2 := "#1e2030"  // Subtle background (bg_dark)
+	darkStep3 := "#222436"  // UI element background (bg_highlight)
+	darkStep4 := "#292e42"  // Hovered UI element background
+	darkStep5 := "#3b4261"  // Active/Selected UI element background (bg_visual)
+	darkStep6 := "#545c7e"  // Subtle borders and separators (dark3)
+	darkStep7 := "#737aa2"  // UI element border and focus rings (dark5)
+	darkStep8 := "#9099b2"  // Hovered UI element border
+	darkStep9 := "#82aaff"  // Solid backgrounds (blue)
+	darkStep10 := "#89b4fa" // Hovered solid backgrounds
+	darkStep11 := "#828bb8" // Low-contrast text (using fg_dark for better contrast)
+	darkStep12 := "#c8d3f5" // High-contrast text (fg)
+
+	// Dark mode accent colors
 	darkRed := "#ff757f"
 	darkOrange := "#ff966c"
 	darkYellow := "#ffc777"
 	darkGreen := "#c3e88d"
 	darkCyan := "#86e1fc"
-	darkBlue := "#82aaff"
+	darkBlue := darkStep9 // Using step 9 for primary
 	darkPurple := "#c099ff"
-	darkBorder := "#3b4261"
 
-	// Light mode colors (Tokyo Night Day)
-	lightBackground := "#e1e2e7"
-	lightCurrentLine := "#d5d6db"
-	lightSelection := "#c8c9ce"
-	lightForeground := "#3760bf"
-	lightComment := "#848cb5"
+	// Light mode colors - Tokyo Night Day variant
+	lightStep1 := "#e1e2e7"  // App background
+	lightStep2 := "#d5d6db"  // Subtle background
+	lightStep3 := "#c8c9ce"  // UI element background
+	lightStep4 := "#b9bac1"  // Hovered UI element background
+	lightStep5 := "#a8aecb"  // Active/Selected UI element background
+	lightStep6 := "#9699a8"  // Subtle borders and separators
+	lightStep7 := "#737a8c"  // UI element border and focus rings
+	lightStep8 := "#5a607d"  // Hovered UI element border
+	lightStep9 := "#2e7de9"  // Solid backgrounds (blue)
+	lightStep10 := "#1a6ce7" // Hovered solid backgrounds
+	lightStep11 := "#8990a3" // Low-contrast text (more muted)
+	lightStep12 := "#3760bf" // High-contrast text
+
+	// Light mode accent colors
 	lightRed := "#f52a65"
 	lightOrange := "#b15c00"
 	lightYellow := "#8c6c3e"
 	lightGreen := "#587539"
 	lightCyan := "#007197"
-	lightBlue := "#2e7de9"
+	lightBlue := lightStep9 // Using step 9 for primary
 	lightPurple := "#9854f1"
-	lightBorder := "#a8aecb"
+
+	// Unused variables to avoid compiler errors (these could be used for hover states)
+	_ = darkStep4
+	_ = darkStep5
+	_ = darkStep10
+	_ = lightStep4
+	_ = lightStep5
+	_ = lightStep10
 
 	theme := &TokyoNightTheme{}
 
@@ -79,44 +103,40 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 
 	// Text colors
 	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
+		Dark:  darkStep1,
+		Light: lightStep1,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep2,
+		Light: lightStep2,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#191B29", // Darker background from palette
-		Light: "#f0f0f5", // Slightly lighter than background
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep3,
+		Light: lightStep3,
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
+	theme.BorderColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep7,
+		Light: lightStep7,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep8,
+		Light: lightStep8,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
+		Dark:  darkStep6,
+		Light: lightStep6,
 	}
 
 	// Diff view colors
@@ -153,12 +173,12 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 		Light: "#f7d8db",
 	}
 	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
+		Dark:  darkStep2,
+		Light: lightStep2,
 	}
 	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#545c7e", // dark3 from palette
-		Light: "#848cb5",
+		Dark:  darkStep3, // dark3 from palette
+		Light: lightStep3,
 	}
 	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
 		Dark:  "#1b2b34",
@@ -171,8 +191,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 
 	// Markdown colors
 	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
 		Dark:  darkPurple,
@@ -203,8 +223,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 		Light: lightOrange,
 	}
 	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
 		Dark:  darkBlue,
@@ -223,14 +243,14 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 		Light: lightCyan,
 	}
 	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 
 	// Syntax highlighting colors
 	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
+		Dark:  darkStep11,
+		Light: lightStep11,
 	}
 	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
 		Dark:  darkPurple,
@@ -261,8 +281,8 @@ func NewTokyoNightTheme() *TokyoNightTheme {
 		Light: lightCyan,
 	}
 	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
+		Dark:  darkStep12,
+		Light: lightStep12,
 	}
 
 	return theme

+ 5 - 9
packages/tui/internal/theme/tron.go

@@ -88,35 +88,31 @@ func NewTronTheme() *TronTheme {
 		Dark:  darkComment,
 		Light: lightComment,
 	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
 
 	// Background colors
 	theme.BackgroundColor = lipgloss.AdaptiveColor{
 		Dark:  darkBackground,
 		Light: lightBackground,
 	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
+	theme.BackgroundSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkCurrentLine,
 		Light: lightCurrentLine,
 	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
+	theme.BackgroundElementColor = lipgloss.AdaptiveColor{
 		Dark:  "#070d14", // Slightly darker than background
 		Light: "#ffffff", // Slightly lighter than background
 	}
 
 	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
+	theme.BorderColor = lipgloss.AdaptiveColor{
 		Dark:  darkBorder,
 		Light: lightBorder,
 	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
+	theme.BorderActiveColor = lipgloss.AdaptiveColor{
 		Dark:  darkCyan,
 		Light: lightCyan,
 	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
+	theme.BorderSubtleColor = lipgloss.AdaptiveColor{
 		Dark:  darkSelection,
 		Light: lightSelection,
 	}

+ 23 - 3
packages/tui/internal/tui/tui.go

@@ -158,7 +158,7 @@ func (a appModel) Init() tea.Cmd {
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
-		shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
+		shouldShow := app.Info.Git && app.Info.Time.Initialized == nil
 		return dialog.ShowInitDialogMsg{Show: shouldShow}
 	})
 
@@ -212,6 +212,27 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		msg.Height -= 2 // Make space for the status bar
 		a.width, a.height = msg.Width, msg.Height
 
+		size := layout.LayoutSizeNormal
+		if a.width < 40 {
+			size = layout.LayoutSizeSmall
+		} else if a.width < 80 {
+			size = layout.LayoutSizeNormal
+		} else {
+			size = layout.LayoutSizeLarge
+		}
+
+		// TODO: move away from global state
+		layout.Current = &layout.LayoutInfo{
+			Size: size,
+			Viewport: layout.Dimensions{
+				Width:  a.width,
+				Height: a.height,
+			},
+			Container: layout.Dimensions{
+				Width: min(a.width, 80),
+			},
+		}
+
 		s, _ := a.status.Update(msg)
 		a.status = s.(core.StatusCmp)
 		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -711,7 +732,6 @@ func (a appModel) View() string {
 	components := []string{
 		a.pages[a.currentPage].View(),
 	}
-
 	components = append(components, a.status.View())
 
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
@@ -943,7 +963,7 @@ func NewModel(app *app.App) tea.Model {
 	})
 
 	// Load custom commands
-	customCommands, err := dialog.LoadCustomCommands(app)
+	customCommands, err := dialog.LoadCustomCommands()
 	if err != nil {
 		slog.Warn("Failed to load custom commands", "error", err)
 	} else {

+ 1 - 0
packages/tui/internal/util/util.go

@@ -11,6 +11,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
 }
 
 func Clamp(v, low, high int) int {
+	// Swap if needed to ensure low <= high
 	if high < low {
 		low, high = high, low
 	}

+ 3 - 3
packages/web/src/content/docs/docs/themes.mdx

@@ -59,9 +59,9 @@ You can define any of the following color keys in your `customTheme`.
 | ----------------- | ------------------------------------------------------- |
 | Base colors       | `primary`, `secondary`, `accent`                        |
 | Status colors     | `error`, `warning`, `success`, `info`                   |
-| Text colors       | `text`, `textMuted`, `textEmphasized`                   |
-| Background colors | `background`, `backgroundSecondary`, `backgroundDarker` |
-| Border colors     | `borderNormal`, `borderFocused`, `borderDim`            |
+| Text colors       | `text`, `textMuted`                                     |
+| Background colors | `background`, `backgroundSubtle`, `backgroundElement`   |
+| Border colors     | `border`, `borderActive`, `borderSubtle`                |
 | Diff view colors  | `diffAdded`, `diffRemoved`, `diffContext`, etc.         |
 
 You don't need to define all the color keys. Any undefined colors will fall back to the default `opencode` theme colors.