wip focus and changes

Kujtim Hoxha created

Change summary

go.mod                                               |   2 
go.sum                                               |  10 
internal/tui/components/chat/editor/editor.go        |  31 +++
internal/tui/components/core/helpers.go              |   5 
internal/tui/components/core/status/keys.go          |  49 ++++++
internal/tui/components/core/status/status.go        | 113 ++++++++++++++
internal/tui/components/dialogs/commands/commands.go |   4 
internal/tui/components/logo/logo.go                 |  78 ---------
internal/tui/keys.go                                 |  27 --
internal/tui/layout/container.go                     |  70 ++++++-
internal/tui/layout/split.go                         |  30 +++
internal/tui/page/chat/chat.go                       |  52 +++--
internal/tui/page/chat/keys.go                       |  46 +++++
internal/tui/styles/crush.go                         |   9 
internal/tui/styles/theme.go                         |  85 ++++++++++
internal/tui/tui.go                                  |   5 
todos.md                                             |   5 
17 files changed, 463 insertions(+), 158 deletions(-)

Detailed changes

go.mod 🔗

@@ -13,7 +13,7 @@ require (
 	github.com/bmatcuk/doublestar/v4 v4.8.1
 	github.com/catppuccin/go v0.3.0
 	github.com/charlievieth/fastwalk v1.0.11
-	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
+	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c

go.sum 🔗

@@ -72,10 +72,10 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367 h1:X+w3YtXyLG3oguOKXvcDT8jQP856YLQsq6SwTE+gqTk=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e h1:+3I/1v7vbN0Vf8Tjm3Q0zdLQqjOM/TjQBvoRDQtoAss=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f h1:vvNB+i59Wp3L6gYcpuhfAdNjr4/e6qM/st3ySWfmZnU=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -84,8 +84,6 @@ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4C
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=

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

@@ -263,8 +263,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *editorCmp) View() tea.View {
 	t := styles.CurrentTheme()
 	cursor := m.textarea.Cursor()
-	cursor.X = cursor.X + m.x + 1
-	cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+	if cursor != nil {
+		cursor.X = cursor.X + m.x + 1
+		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+	}
 	if len(m.attachments) == 0 {
 		content := t.S().Base.Padding(1).Render(
 			m.textarea.View(),
@@ -358,11 +360,15 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 	t := styles.CurrentTheme()
 	ta := textarea.New()
 	ta.SetStyles(t.S().TextArea)
-	ta.SetPromptFunc(4, func(lineIndex int) string {
+	ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
 		if lineIndex == 0 {
 			return "  > "
 		}
-		return t.S().Base.Foreground(t.Blue).Render("::: ")
+		if focused {
+			return t.S().Base.Foreground(t.Blue).Render("::: ")
+		} else {
+			return t.S().Muted.Render("::: ")
+		}
 	})
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
@@ -379,6 +385,23 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 	return ta
 }
 
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+	c.textarea.Blur()
+	return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+	logging.Info("Focusing editor textarea")
+	return c.textarea.Focus()
+}
+
+// IsFocused implements Container.
+func (c *editorCmp) IsFocused() bool {
+	return c.textarea.Focused()
+}
+
 func NewEditorCmp(app *app.App) util.Model {
 	ta := CreateTextArea(nil)
 	return &editorCmp{

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

@@ -26,10 +26,11 @@ func Title(title string, width int) string {
 	char := "╱"
 	length := lipgloss.Width(title) + 1
 	remainingWidth := width - length
-	lineStyle := t.S().Base.Foreground(t.Primary)
 	titleStyle := t.S().Base.Foreground(t.Primary)
 	if remainingWidth > 0 {
-		title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
+		lines := strings.Repeat(char, remainingWidth)
+		lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
+		title = titleStyle.Render(title) + " " + lines
 	}
 	return title
 }

internal/tui/components/core/status/keys.go 🔗

@@ -0,0 +1,49 @@
+package status
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Tab,
+	Commands,
+	Help key.Binding
+}
+
+func DefaultKeyMap(tabHelp string) KeyMap {
+	return KeyMap{
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", tabHelp),
+		),
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
+		),
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
+			key.WithHelp("ctrl+?", "more"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Tab,
+		k.Commands,
+		k.Help,
+	}
+}

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

@@ -0,0 +1,113 @@
+package status
+
+import (
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/pubsub"
+	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type StatusCmp interface {
+	util.Model
+}
+
+type statusCmp struct {
+	info       util.InfoMsg
+	width      int
+	messageTTL time.Duration
+	session    session.Session
+	help       help.Model
+}
+
+// clearMessageCmd is a command that clears status messages after a timeout
+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
+		return util.ClearStatusMsg{}
+	})
+}
+
+func (m statusCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		return m, nil
+
+	// Handle status info
+	case util.InfoMsg:
+		m.info = msg
+		ttl := msg.TTL
+		if ttl == 0 {
+			ttl = m.messageTTL
+		}
+		return m, m.clearMessageCmd(ttl)
+	case util.ClearStatusMsg:
+		m.info = util.InfoMsg{}
+
+	// Handle persistent logs
+	case pubsub.Event[logging.LogMessage]:
+		if msg.Payload.Persist {
+			switch msg.Payload.Level {
+			case "error":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeError,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "info":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "warn":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeWarn,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			default:
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m statusCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+	if m.info.Msg != "" {
+		switch m.info.Type {
+		case util.InfoTypeError:
+			status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		case util.InfoTypeWarn:
+			status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		default:
+			status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		}
+	}
+	return tea.NewView(status)
+}
+
+func NewStatusCmp() StatusCmp {
+	t := styles.CurrentTheme()
+	help := help.New()
+	help.Styles = t.S().Help
+	return &statusCmp{
+		messageTTL: 10 * time.Second,
+		help:       help,
+	}
+}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -160,9 +160,9 @@ func (c *commandDialogCmp) commandTypeRadio() string {
 	iconSelected := "◉"
 	iconUnselected := "○"
 	if c.commandType == SystemCommands {
-		return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
 	}
-	return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
 }
 
 func (c *commandDialogCmp) listWidth() int {

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

@@ -10,8 +10,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/slice"
-	"github.com/lucasb-eyer/go-colorful"
-	"github.com/rivo/uniseg"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
 )
 
 // letterform represents a letterform. It can be stretched horizontally by
@@ -46,7 +45,7 @@ func Render(version string, compact bool, o Opts) string {
 	crushWidth := lipgloss.Width(crush)
 	b := new(strings.Builder)
 	for r := range strings.SplitSeq(crush, "\n") {
-		fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
 	}
 	crush = b.String()
 
@@ -312,76 +311,3 @@ func stretchLetterformPart(s string, p letterformProps) string {
 	}
 	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
 }
-
-// applyForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func applyForegroundGrad(input string, color1, color2 color.Color) string {
-	if input == "" {
-		return ""
-	}
-
-	var o strings.Builder
-	if len(input) == 1 {
-		return lipgloss.NewStyle().Foreground(color1).Render(input)
-	}
-
-	var clusters []string
-	gr := uniseg.NewGraphemes(input)
-	for gr.Next() {
-		clusters = append(clusters, string(gr.Runes()))
-	}
-
-	ramp := blendColors(len(clusters), color1, color2)
-	for i, c := range ramp {
-		fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
-	}
-
-	return o.String()
-}
-
-// blendColors returns a slice of colors blended between the given keys.
-// Blending is done in Hcl to stay in gamut.
-func blendColors(size int, stops ...color.Color) []color.Color {
-	if len(stops) < 2 {
-		return nil
-	}
-
-	stopsPrime := make([]colorful.Color, len(stops))
-	for i, k := range stops {
-		stopsPrime[i], _ = colorful.MakeColor(k)
-	}
-
-	numSegments := len(stopsPrime) - 1
-	blended := make([]color.Color, 0, size)
-
-	// Calculate how many colors each segment should have.
-	segmentSizes := make([]int, numSegments)
-	baseSize := size / numSegments
-	remainder := size % numSegments
-
-	// Distribute the remainder across segments.
-	for i := range numSegments {
-		segmentSizes[i] = baseSize
-		if i < remainder {
-			segmentSizes[i]++
-		}
-	}
-
-	// Generate colors for each segment.
-	for i := range numSegments {
-		c1 := stopsPrime[i]
-		c2 := stopsPrime[i+1]
-		segmentSize := segmentSizes[i]
-
-		for j := range segmentSize {
-			var t float64
-			if segmentSize > 1 {
-				t = float64(j) / float64(segmentSize-1)
-			}
-			c := c1.BlendHcl(c2, t)
-			blended = append(blended, c)
-		}
-	}
-
-	return blended
-}

internal/tui/keys.go 🔗

@@ -6,13 +6,11 @@ import (
 )
 
 type KeyMap struct {
-	Logs        key.Binding
-	Quit        key.Binding
-	Help        key.Binding
-	Commands    key.Binding
-	FilePicker  key.Binding
-	Models      key.Binding
-	SwitchTheme key.Binding
+	Logs       key.Binding
+	Quit       key.Binding
+	Help       key.Binding
+	Commands   key.Binding
+	FilePicker key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -21,7 +19,6 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+l"),
 			key.WithHelp("ctrl+l", "logs"),
 		),
-
 		Quit: key.NewBinding(
 			key.WithKeys("ctrl+c"),
 			key.WithHelp("ctrl+c", "quit"),
@@ -31,24 +28,14 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+_"),
 			key.WithHelp("ctrl+?", "toggle help"),
 		),
-
 		Commands: key.NewBinding(
-			key.WithKeys("ctrl+k"),
-			key.WithHelp("ctrl+k", "commands"),
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
 		),
 		FilePicker: key.NewBinding(
 			key.WithKeys("ctrl+f"),
 			key.WithHelp("ctrl+f", "select files to upload"),
 		),
-		Models: key.NewBinding(
-			key.WithKeys("ctrl+o"),
-			key.WithHelp("ctrl+o", "model selection"),
-		),
-
-		SwitchTheme: key.NewBinding(
-			key.WithKeys("ctrl+t"),
-			key.WithHelp("ctrl+t", "switch theme"),
-		),
 	}
 }
 

internal/tui/layout/container.go 🔗

@@ -13,10 +13,12 @@ type Container interface {
 	Sizeable
 	Bindings
 	Positionable
+	Focusable
 }
 type container struct {
-	width  int
-	height int
+	width     int
+	height    int
+	isFocused bool
 
 	x, y int
 
@@ -35,14 +37,39 @@ type container struct {
 	borderStyle  lipgloss.Border
 }
 
+type ContainerOption func(*container)
+
+func NewContainer(content util.Model, options ...ContainerOption) Container {
+	c := &container{
+		content:     content,
+		borderStyle: lipgloss.NormalBorder(),
+	}
+
+	for _, option := range options {
+		option(c)
+	}
+
+	return c
+}
+
 func (c *container) Init() tea.Cmd {
 	return c.content.Init()
 }
 
 func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	u, cmd := c.content.Update(msg)
-	c.content = u.(util.Model)
-	return c, cmd
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		if c.IsFocused() {
+			u, cmd := c.content.Update(msg)
+			c.content = u.(util.Model)
+			return c, cmd
+		}
+		return c, nil
+	default:
+		u, cmd := c.content.Update(msg)
+		c.content = u.(util.Model)
+		return c, cmd
+	}
 }
 
 func (c *container) View() tea.View {
@@ -80,7 +107,8 @@ func (c *container) View() tea.View {
 
 	contentView := c.content.View()
 	view := tea.NewView(style.Render(contentView.String()))
-	view.SetCursor(contentView.Cursor())
+	cursor := contentView.Cursor()
+	view.SetCursor(cursor)
 	return view
 }
 
@@ -136,19 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
 	return []key.Binding{}
 }
 
-type ContainerOption func(*container)
-
-func NewContainer(content util.Model, options ...ContainerOption) Container {
-	c := &container{
-		content:     content,
-		borderStyle: lipgloss.NormalBorder(),
+// Blur implements Container.
+func (c *container) Blur() tea.Cmd {
+	c.isFocused = false
+	if focusable, ok := c.content.(Focusable); ok {
+		return focusable.Blur()
 	}
+	return nil
+}
 
-	for _, option := range options {
-		option(c)
+// Focus implements Container.
+func (c *container) Focus() tea.Cmd {
+	c.isFocused = true
+	if focusable, ok := c.content.(Focusable); ok {
+		return focusable.Focus()
 	}
+	return nil
+}
 
-	return c
+// IsFocused implements Container.
+func (c *container) IsFocused() bool {
+	isFocused := c.isFocused
+	if focusable, ok := c.content.(Focusable); ok {
+		isFocused = isFocused || focusable.IsFocused()
+	}
+	return isFocused
 }
 
 // Padding options

internal/tui/layout/split.go 🔗

@@ -8,6 +8,14 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+type LayoutPanel string
+
+const (
+	LeftPanel   LayoutPanel = "left"
+	RightPanel  LayoutPanel = "right"
+	BottomPanel LayoutPanel = "bottom"
+)
+
 type SplitPaneLayout interface {
 	util.Model
 	Sizeable
@@ -19,6 +27,8 @@ type SplitPaneLayout interface {
 	ClearLeftPanel() tea.Cmd
 	ClearRightPanel() tea.Cmd
 	ClearBottomPanel() tea.Cmd
+
+	FocusPanel(panel LayoutPanel) tea.Cmd
 }
 
 type splitPaneLayout struct {
@@ -279,6 +289,26 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
 	return keys
 }
 
+func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
+	panels := map[LayoutPanel]Container{
+		LeftPanel:   s.leftPanel,
+		RightPanel:  s.rightPanel,
+		BottomPanel: s.bottomPanel,
+	}
+	var cmds []tea.Cmd
+	for p, container := range panels {
+		if container == nil {
+			continue
+		}
+		if p == panel {
+			cmds = append(cmds, container.Focus())
+		} else {
+			cmds = append(cmds, container.Blur())
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
 func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
 	layout := &splitPaneLayout{
 		ratio:         0.8,

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

@@ -6,6 +6,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -19,32 +20,28 @@ import (
 
 var ChatPage page.PageID = "chat"
 
+type ChatFocusedMsg struct {
+	Focused bool // True if the chat input is focused, false otherwise
+}
+
 type chatPage struct {
 	app *app.App
 
 	layout layout.SplitPaneLayout
 
 	session session.Session
-}
 
-type ChatKeyMap struct {
-	NewSession key.Binding
-	Cancel     key.Binding
-}
+	keyMap KeyMap
 
-var keyMap = ChatKeyMap{
-	NewSession: key.NewBinding(
-		key.WithKeys("ctrl+n"),
-		key.WithHelp("ctrl+n", "new session"),
-	),
-	Cancel: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "cancel"),
-	),
+	chatFocused bool
 }
 
 func (p *chatPage) Init() tea.Cmd {
-	return p.layout.Init()
+	cmd := p.layout.Init()
+	return tea.Batch(
+		cmd,
+		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
+	)
 }
 
 func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -79,13 +76,28 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		p.session = msg
 	case tea.KeyPressMsg:
 		switch {
-		case key.Matches(msg, keyMap.NewSession):
+		case key.Matches(msg, p.keyMap.NewSession):
 			p.session = session.Session{}
 			return p, tea.Batch(
 				p.clearMessages(),
 				util.CmdHandler(chat.SessionClearedMsg{}),
 			)
-		case key.Matches(msg, keyMap.Cancel):
+
+		case key.Matches(msg, p.keyMap.Tab):
+			logging.Info("Tab key pressed, toggling chat focus")
+			if p.session.ID == "" {
+				return p, nil
+			}
+			p.chatFocused = !p.chatFocused
+			if p.chatFocused {
+				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
+				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
+			} else {
+				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
+				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
+			}
+			return p, tea.Batch(cmds...)
+		case key.Matches(msg, p.keyMap.Cancel):
 			if p.session.ID != "" {
 				// Cancel the current session's generation process
 				// This allows users to interrupt long-running operations
@@ -148,11 +160,6 @@ func (p *chatPage) View() tea.View {
 	return p.layout.View()
 }
 
-func (p *chatPage) BindingKeys() []key.Binding {
-	bindings := layout.KeyMapToSlice(keyMap)
-	return bindings
-}
-
 func NewChatPage(app *app.App) util.Model {
 	sidebarContainer := layout.NewContainer(
 		sidebar.NewSidebarCmp(),
@@ -169,5 +176,6 @@ func NewChatPage(app *app.App) util.Model {
 			layout.WithFixedBottomHeight(5),
 			layout.WithFixedRightWidth(31),
 		),
+		keyMap: DefaultKeyMap(),
 	}
 }

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

@@ -1 +1,47 @@
 package chat
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+	NewSession key.Binding
+	Cancel     key.Binding
+	Tab        key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		NewSession: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "new session"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Tab,
+	}
+}

internal/tui/styles/crush.go 🔗

@@ -24,10 +24,11 @@ func NewCrushTheme() *Theme {
 		BgOverlay: charmtone.Iron,
 
 		// Foregrounds
-		FgBase:     charmtone.Ash,
-		FgMuted:    charmtone.Squid,
-		FgSubtle:   charmtone.Oyster,
-		FgSelected: charmtone.Salt,
+		FgBase:      charmtone.Ash,
+		FgMuted:     charmtone.Squid,
+		FgHalfMuted: charmtone.Smoke,
+		FgSubtle:    charmtone.Oyster,
+		FgSelected:  charmtone.Salt,
 
 		// Borders
 		Border:      charmtone.Charcoal,

internal/tui/styles/theme.go 🔗

@@ -3,6 +3,7 @@ package styles
 import (
 	"fmt"
 	"image/color"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/textarea"
@@ -10,6 +11,8 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/rivo/uniseg"
 )
 
 const (
@@ -35,10 +38,11 @@ type Theme struct {
 	BgSubtle  color.Color
 	BgOverlay color.Color
 
-	FgBase     color.Color
-	FgMuted    color.Color
-	FgSubtle   color.Color
-	FgSelected color.Color
+	FgBase      color.Color
+	FgMuted     color.Color
+	FgHalfMuted color.Color
+	FgSubtle    color.Color
+	FgSelected  color.Color
 
 	Border      color.Color
 	BorderFocus color.Color
@@ -491,3 +495,76 @@ func Lighten(c color.Color, percent float64) color.Color {
 		A: uint8(a >> 8),
 	}
 }
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+
+	var o strings.Builder
+	if len(input) == 1 {
+		return lipgloss.NewStyle().Foreground(color1).Render(input)
+	}
+
+	var clusters []string
+	gr := uniseg.NewGraphemes(input)
+	for gr.Next() {
+		clusters = append(clusters, string(gr.Runes()))
+	}
+
+	ramp := blendColors(len(clusters), color1, color2)
+	for i, c := range ramp {
+		fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
+	}
+
+	return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	stopsPrime := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		stopsPrime[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stopsPrime) - 1
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := stopsPrime[i]
+		c2 := stopsPrime[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			var t float64
+			if segmentSize > 1 {
+				t = float64(j) / float64(segmentSize-1)
+			}
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}

internal/tui/tui.go 🔗

@@ -12,6 +12,7 @@ import (
 	cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
 	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 	"github.com/opencode-ai/opencode/internal/tui/components/core"
+	"github.com/opencode-ai/opencode/internal/tui/components/core/status"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
 	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
@@ -34,7 +35,7 @@ type appModel struct {
 	pages        map[page.PageID]util.Model
 	loadedPages  map[page.PageID]bool
 
-	status core.StatusCmp
+	status status.StatusCmp
 
 	app *app.App
 
@@ -288,7 +289,7 @@ func New(app *app.App) tea.Model {
 	model := &appModel{
 		currentPage: startPage,
 		app:         app,
-		status:      core.NewStatusCmp(app.LSPClients),
+		status:      status.NewStatusCmp(),
 		loadedPages: make(map[page.PageID]bool),
 		keyMap:      DefaultKeyMap(),
 

todos.md 🔗

@@ -13,3 +13,8 @@
 - [x] Sessions dialog
 - [ ] Models
 - [~] Move sessions and model dialog to the commands
+
+## Investigate
+
+- [ ] Events when tool error
+- [ ] Fancy Spinner