.opencode.json 🔗
@@ -1,11 +1,11 @@
{
"$schema": "./opencode-schema.json",
"lsp": {
- "gopls": {
+ "Go": {
"command": "gopls"
}
},
"tui": {
- "theme": "opencode-dark"
+ "theme": "charm"
}
}
Kujtim Hoxha created
.opencode.json | 4
cspell.json | 2
diff.diff | 124 ++++++++++
go.mod | 1
go.sum | 2
internal/tui/components/chat/chat.go | 2
internal/tui/components/chat/editor/editor.go | 64 +---
internal/tui/components/chat/sidebar/sidebar.go | 205 +++++++++++++++++
internal/tui/components/core/helpers.go | 63 +++++
internal/tui/components/logo/logo.go | 2
internal/tui/layout/container.go | 9
internal/tui/layout/split.go | 50 +++
internal/tui/page/chat/chat.go | 173 ++++++++++++++
internal/tui/page/chat/keys.go | 1
internal/tui/styles/crush.go | 44 +++
internal/tui/styles/theme.go | 229 +++++++++++++++++++
internal/tui/tui.go | 23 +
todos.md | 8
18 files changed, 938 insertions(+), 68 deletions(-)
@@ -1,11 +1,11 @@
{
"$schema": "./opencode-schema.json",
"lsp": {
- "gopls": {
+ "Go": {
"command": "gopls"
}
},
"tui": {
- "theme": "opencode-dark"
+ "theme": "charm"
}
}
@@ -1 +1 @@
-{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
+{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps"],"version":"0.2","language":"en","flagWords":[]}
@@ -0,0 +1,124 @@
+diff --git a/.opencode.json b/.opencode.json
+index 75e357d..59be1e8 100644
+--- a/.opencode.json
++++ b/.opencode.json
+@@ -6,6 +6,6 @@
+ }
+ },
+ "tui": {
+- "theme": "opencode-dark"
++ "theme": "charm"
+ }
+ }
+diff --git a/go.mod b/go.mod
+index 18ad042..940a8e8 100644
+--- a/go.mod
++++ b/go.mod
+@@ -36,6 +36,8 @@ require (
+ github.com/stretchr/testify v1.10.0
+ )
+
++require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 // indirect
++
+ require (
+ cloud.google.com/go v0.116.0 // indirect
+ cloud.google.com/go/auth v0.13.0 // indirect
+diff --git a/go.sum b/go.sum
+index f6e08b7..8f347ed 100644
+--- a/go.sum
++++ b/go.sum
+@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
+diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
+index 2ee0b04..52c4dae 100644
+--- a/internal/tui/components/chat/chat.go
++++ b/internal/tui/components/chat/chat.go
+@@ -95,7 +95,7 @@ func lspsConfigured() string {
+ func logoBlock() string {
+ t := theme.CurrentTheme()
+ return logo.Render(version.Version, true, logo.Opts{
+- FieldColor: t.Accent(),
++ FieldColor: t.Secondary(),
+ TitleColorA: t.Primary(),
+ TitleColorB: t.Secondary(),
+ CharmColor: t.Primary(),
+diff --git a/internal/tui/tui.go b/internal/tui/tui.go
+index 9e8a62a..3f07956 100644
+--- a/internal/tui/tui.go
++++ b/internal/tui/tui.go
+@@ -18,6 +18,7 @@ import (
+ "github.com/opencode-ai/opencode/internal/tui/util"
+ )
+
++// appModel represents the main application model that manages pages, dialogs, and UI state.
+ type appModel struct {
+ width, height int
+ keyMap KeyMap
+@@ -35,6 +36,7 @@ type appModel struct {
+ completions completions.Completions
+ }
+
++// Init initializes the application model and returns initial commands.
+ func (a appModel) Init() tea.Cmd {
+ var cmds []tea.Cmd
+ cmd := a.pages[a.currentPage].Init()
+@@ -46,6 +48,7 @@ func (a appModel) Init() tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// Update handles incoming messages and updates the application state.
+ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ var cmd tea.Cmd
+@@ -111,6 +114,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ return a, tea.Batch(cmds...)
+ }
+
++// handleWindowResize processes window resize events and updates all components.
+ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ var cmds []tea.Cmd
+ msg.Height -= 1 // Make space for the status bar
+@@ -134,6 +138,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
+ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ switch {
+ // completions
+@@ -182,11 +187,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ }
+ }
+
+-// RegisterCommand adds a command to the command dialog
+-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
+-// a.commands = append(a.commands, cmd)
+-// }
+-
++// moveToPage handles navigation between different pages in the application.
+ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ if a.app.CoderAgent.IsBusy() {
+ // For now we don't move to any page if the agent is busy
+@@ -209,6 +210,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// View renders the complete application interface including pages, dialogs, and overlays.
+ func (a *appModel) View() tea.View {
+ pageView := a.pages[a.currentPage].View()
+ components := []string{
+@@ -252,6 +254,7 @@ func (a *appModel) View() tea.View {
+ return view
+ }
+
++// New creates and initializes a new TUI application model.
+ func New(app *app.App) tea.Model {
+ startPage := page.ChatPage
+ model := &appModel{
@@ -18,6 +18,7 @@ require (
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa
+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
@@ -95,7 +95,7 @@ func lspsConfigured() string {
func logoBlock() string {
t := theme.CurrentTheme()
return logo.Render(version.Version, true, logo.Opts{
- FieldColor: t.Accent(),
+ FieldColor: t.Secondary(),
TitleColorA: t.Primary(),
TitleColorB: t.Secondary(),
CharmColor: t.Primary(),
@@ -261,29 +261,25 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *editorCmp) View() tea.View {
- t := theme.CurrentTheme()
-
- // Style the prompt with theme colors
- style := lipgloss.NewStyle().
- Padding(0, 0, 0, 1).
- Bold(true).
- Foreground(t.Primary())
-
+ t := styles.CurrentTheme()
cursor := m.textarea.Cursor()
- cursor.X = cursor.X + m.x + 2
- cursor.Y = cursor.Y + m.y + 1
+ cursor.X = cursor.X + m.x + 1
+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
if len(m.attachments) == 0 {
- view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
+ content := t.S().Base.Padding(1).Render(
+ m.textarea.View(),
+ )
+ view := tea.NewView(content)
view.SetCursor(cursor)
return view
}
- m.textarea.SetHeight(m.height - 1)
- view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
- m.attachmentsContent(),
- lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
+ content := t.S().Base.Padding(0, 1, 1, 1).Render(
+ lipgloss.JoinVertical(lipgloss.Top,
+ m.attachmentsContent(),
m.textarea.View(),
),
- ))
+ )
+ view := tea.NewView(content)
view.SetCursor(cursor)
return view
}
@@ -291,8 +287,8 @@ func (m *editorCmp) View() tea.View {
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
- m.textarea.SetWidth(width - 3) // account for the prompt and padding right
- m.textarea.SetHeight(height)
+ m.textarea.SetWidth(width - 2) // adjust for padding
+ m.textarea.SetHeight(height - 2) // adjust for padding
return nil
}
@@ -359,32 +355,18 @@ func (m *editorCmp) startCompletions() tea.Msg {
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
- t := theme.CurrentTheme()
- bgColor := t.Background()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
+ t := styles.CurrentTheme()
ta := textarea.New()
- s := textarea.DefaultDarkStyles()
- b := s.Blurred
- b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- b.CursorLine = styles.BaseStyle().Background(bgColor)
- b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
- f := s.Focused
- f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- f.CursorLine = styles.BaseStyle().Background(bgColor)
- f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
- s.Focused = f
- s.Blurred = b
- ta.SetStyles(s)
-
- ta.Prompt = " "
+ ta.SetStyles(t.S().TextArea)
+ ta.SetPromptFunc(2, func(lineIndex int) string {
+ if lineIndex == 0 {
+ return "> "
+ }
+ return t.S().Muted.Render(": ")
+ })
ta.ShowLineNumbers = false
ta.CharLimit = -1
+ ta.Placeholder = "Tell me more about this project..."
ta.SetVirtualCursor(false)
if existing != nil {
@@ -0,0 +1,205 @@
+package sidebar
+
+import (
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/pubsub"
+ "github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/logo"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+ "github.com/opencode-ai/opencode/internal/version"
+)
+
+const (
+ logoBreakpoint = 65
+)
+
+type Sidebar interface {
+ util.Model
+ layout.Sizeable
+}
+
+type sidebarCmp struct {
+ width, height int
+ session session.Session
+ logo string
+ cwd string
+}
+
+func NewSidebarCmp() Sidebar {
+ return &sidebarCmp{}
+}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+ m.logo = m.logoBlock(false)
+ m.cwd = cwd()
+ return nil
+}
+
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case chat.SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ }
+ case pubsub.Event[session.Session]:
+ if msg.Type == pubsub.UpdatedEvent {
+ if m.session.ID == msg.Payload.ID {
+ m.session = msg.Payload
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m *sidebarCmp) View() tea.View {
+ t := styles.CurrentTheme()
+ parts := []string{
+ m.logo,
+ }
+
+ if m.session.ID != "" {
+ parts = append(parts, t.S().Muted.Render(m.session.Title), "")
+ }
+
+ parts = append(parts,
+ m.cwd,
+ "",
+ m.lspBlock(),
+ "",
+ m.mcpBlock(),
+ )
+
+ return tea.NewView(
+ lipgloss.JoinVertical(lipgloss.Left, parts...),
+ )
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+ if width < logoBreakpoint && m.width >= logoBreakpoint {
+ m.logo = m.logoBlock(true)
+ } else if width >= logoBreakpoint && m.width < logoBreakpoint {
+ m.logo = m.logoBlock(false)
+ }
+
+ m.width = width
+ m.height = height
+ return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *sidebarCmp) logoBlock(compact bool) string {
+ t := styles.CurrentTheme()
+ return logo.Render(version.Version, compact, logo.Opts{
+ FieldColor: t.Primary,
+ TitleColorA: t.Secondary,
+ TitleColorB: t.Primary,
+ CharmColor: t.Secondary,
+ VersionColor: t.Primary,
+ })
+}
+
+func (m *sidebarCmp) lspBlock() string {
+ maxWidth := min(m.width, 58)
+ t := styles.CurrentTheme()
+
+ section := t.S().Muted.Render(
+ core.Section("Configured LSPs", maxWidth),
+ )
+
+ lspList := []string{section, ""}
+
+ lsp := config.Get().LSP
+ if len(lsp) == 0 {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Muted.Render("No LSPs configured."),
+ )
+ }
+
+ for n, l := range lsp {
+ iconColor := t.Success
+ if l.Disabled {
+ iconColor = t.FgMuted
+ }
+ lspList = append(lspList,
+ core.Status(
+ core.StatusOpts{
+ IconColor: iconColor,
+ Title: n,
+ Description: l.Command,
+ },
+ m.width,
+ ),
+ )
+ }
+
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ lspList...,
+ )
+}
+
+func (m *sidebarCmp) mcpBlock() string {
+ maxWidth := min(m.width, 58)
+ t := styles.CurrentTheme()
+
+ section := t.S().Muted.Render(
+ core.Section("Configured MCPs", maxWidth),
+ )
+
+ mcpList := []string{section, ""}
+
+ mcp := config.Get().MCPServers
+ if len(mcp) == 0 {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Muted.Render("No MCPs configured."),
+ )
+ }
+
+ for n, l := range mcp {
+ iconColor := t.Success
+ mcpList = append(mcpList,
+ core.Status(
+ core.StatusOpts{
+ IconColor: iconColor,
+ Title: n,
+ Description: l.Command,
+ },
+ m.width,
+ ),
+ )
+ }
+
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ mcpList...,
+ )
+}
+
+func cwd() string {
+ cwd := config.WorkingDirectory()
+ t := styles.CurrentTheme()
+ // replace home directory with ~
+ homeDir, err := os.UserHomeDir()
+ if err == nil {
+ cwd = strings.ReplaceAll(cwd, homeDir, "~")
+ }
+ return t.S().Muted.Render(cwd)
+}
@@ -0,0 +1,63 @@
+package core
+
+import (
+ "image/color"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+)
+
+func Section(title string, width int) string {
+ t := styles.CurrentTheme()
+ char := "─"
+ length := len(title) + 1
+ remainingWidth := width - length
+ if remainingWidth > 0 {
+ title = title + " " + t.S().Subtle.Render(strings.Repeat(char, remainingWidth))
+ }
+ return title
+}
+
+type StatusOpts struct {
+ Icon string
+ IconColor color.Color
+ Title string
+ TitleColor color.Color
+ Description string
+ DescriptionColor color.Color
+}
+
+func Status(ops StatusOpts, width int) string {
+ t := styles.CurrentTheme()
+ icon := "●"
+ iconColor := t.Success
+ if ops.Icon != "" {
+ icon = ops.Icon
+ }
+ if ops.IconColor != nil {
+ iconColor = ops.IconColor
+ }
+ title := ops.Title
+ titleColor := t.FgMuted
+ if ops.TitleColor != nil {
+ titleColor = ops.TitleColor
+ }
+ description := ops.Description
+ descriptionColor := t.FgSubtle
+ if ops.DescriptionColor != nil {
+ descriptionColor = ops.DescriptionColor
+ }
+ icon = t.S().Base.Foreground(iconColor).Render(icon)
+ title = t.S().Base.Foreground(titleColor).Render(title)
+ if description != "" {
+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
+ }
+ description = t.S().Base.Foreground(descriptionColor).Render(description)
+ return strings.Join([]string{
+ icon,
+ title,
+ description,
+ }, " ")
+}
@@ -63,7 +63,7 @@ func Render(version string, compact bool, o Opts) string {
// Narrow version.
if compact {
field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
- return strings.Join([]string{field, field, crush, field}, "\n")
+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
}
fieldHeight := lipgloss.Height(crush)
@@ -4,7 +4,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -46,12 +46,11 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (c *container) View() tea.View {
- t := theme.CurrentTheme()
- style := lipgloss.NewStyle()
+ t := styles.CurrentTheme()
width := c.width
height := c.height
- style = style.Background(t.Background())
+ style := t.S().Base
// Apply border if any side is enabled
if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -69,7 +68,7 @@ func (c *container) View() tea.View {
width--
}
style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
- style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
+ style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
}
style = style.
Width(width).
@@ -22,14 +22,18 @@ type SplitPaneLayout interface {
}
type splitPaneLayout struct {
- width int
- height int
+ width int
+ height int
+
ratio float64
verticalRatio float64
rightPanel Container
leftPanel Container
bottomPanel Container
+
+ fixedBottomHeight int // Fixed height for the bottom panel, if any
+ fixedRightWidth int // Fixed width for the right panel, if any
}
type SplitPaneOption func(*splitPaneLayout)
@@ -141,8 +145,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
var topHeight, bottomHeight int
var cmds []tea.Cmd
if s.bottomPanel != nil {
- topHeight = int(float64(height) * s.verticalRatio)
- bottomHeight = height - topHeight
+ if s.fixedBottomHeight > 0 {
+ bottomHeight = s.fixedBottomHeight
+ topHeight = height - bottomHeight
+ } else {
+ topHeight = int(float64(height) * s.verticalRatio)
+ bottomHeight = height - topHeight
+ if bottomHeight <= 0 {
+ bottomHeight = 2
+ topHeight = height - bottomHeight
+ }
+ }
} else {
topHeight = height
bottomHeight = 0
@@ -150,8 +163,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
var leftWidth, rightWidth int
if s.leftPanel != nil && s.rightPanel != nil {
- leftWidth = int(float64(width) * s.ratio)
- rightWidth = width - leftWidth
+ if s.fixedRightWidth > 0 {
+ rightWidth = s.fixedRightWidth
+ leftWidth = width - rightWidth
+ } else {
+ leftWidth = int(float64(width) * s.ratio)
+ rightWidth = width - leftWidth
+ if rightWidth <= 0 {
+ rightWidth = 2
+ leftWidth = width - rightWidth
+ }
+ }
} else if s.leftPanel != nil {
leftWidth = width
rightWidth = 0
@@ -260,8 +282,8 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
layout := &splitPaneLayout{
- ratio: 0.7,
- verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
+ ratio: 0.8,
+ verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
}
for _, option := range options {
option(layout)
@@ -298,3 +320,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
s.verticalRatio = ratio
}
}
+
+func WithFixedBottomHeight(height int) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.fixedBottomHeight = height
+ }
+}
+
+func WithFixedRightWidth(width int) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.fixedRightWidth = width
+ }
+}
@@ -0,0 +1,173 @@
+package chat
+
+import (
+ "context"
+
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/message"
+ "github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/sidebar"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/page"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+var ChatPage page.PageID = "chat"
+
+type chatPage struct {
+ app *app.App
+
+ layout layout.SplitPaneLayout
+
+ session session.Session
+}
+
+type ChatKeyMap struct {
+ NewSession key.Binding
+ Cancel key.Binding
+}
+
+var keyMap = ChatKeyMap{
+ NewSession: key.NewBinding(
+ key.WithKeys("ctrl+n"),
+ key.WithHelp("ctrl+n", "new session"),
+ ),
+ Cancel: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+}
+
+func (p *chatPage) Init() tea.Cmd {
+ return p.layout.Init()
+}
+
+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ cmd := p.layout.SetSize(msg.Width, msg.Height)
+ cmds = append(cmds, cmd)
+ case chat.SendMsg:
+ cmd := p.sendMessage(msg.Text, msg.Attachments)
+ if cmd != nil {
+ return p, cmd
+ }
+ case commands.CommandRunCustomMsg:
+ // Check if the agent is busy before executing custom commands
+ if p.app.CoderAgent.IsBusy() {
+ return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
+ }
+
+ // Handle custom command execution
+ cmd := p.sendMessage(msg.Content, nil)
+ if cmd != nil {
+ return p, cmd
+ }
+ case chat.SessionSelectedMsg:
+ if p.session.ID == "" {
+ cmd := p.setMessages()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ p.session = msg
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, keyMap.NewSession):
+ p.session = session.Session{}
+ return p, tea.Batch(
+ p.clearMessages(),
+ util.CmdHandler(chat.SessionClearedMsg{}),
+ )
+ case key.Matches(msg, keyMap.Cancel):
+ if p.session.ID != "" {
+ // Cancel the current session's generation process
+ // This allows users to interrupt long-running operations
+ p.app.CoderAgent.Cancel(p.session.ID)
+ return p, nil
+ }
+ }
+ }
+ u, cmd := p.layout.Update(msg)
+ cmds = append(cmds, cmd)
+ p.layout = u.(layout.SplitPaneLayout)
+
+ return p, tea.Batch(cmds...)
+}
+
+func (p *chatPage) setMessages() tea.Cmd {
+ messagesContainer := layout.NewContainer(
+ chat.NewMessagesListCmp(p.app),
+ layout.WithPadding(1, 1, 0, 1),
+ )
+ return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
+}
+
+func (p *chatPage) clearMessages() tea.Cmd {
+ return p.layout.ClearLeftPanel()
+}
+
+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+ var cmds []tea.Cmd
+ if p.session.ID == "" {
+ session, err := p.app.Sessions.Create(context.Background(), "New Session")
+ if err != nil {
+ return util.ReportError(err)
+ }
+
+ p.session = session
+ cmd := p.setMessages()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+ }
+
+ _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ return tea.Batch(cmds...)
+}
+
+func (p *chatPage) SetSize(width, height int) tea.Cmd {
+ return p.layout.SetSize(width, height)
+}
+
+func (p *chatPage) GetSize() (int, int) {
+ return p.layout.GetSize()
+}
+
+func (p *chatPage) View() tea.View {
+ return p.layout.View()
+}
+
+func (p *chatPage) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(keyMap)
+ return bindings
+}
+
+func NewChatPage(app *app.App) util.Model {
+ sidebarContainer := layout.NewContainer(
+ sidebar.NewSidebarCmp(),
+ layout.WithPadding(1, 1, 1, 1),
+ )
+ editorContainer := layout.NewContainer(
+ editor.NewEditorCmp(app),
+ )
+ return &chatPage{
+ app: app,
+ layout: layout.NewSplitPane(
+ layout.WithRightPanel(sidebarContainer),
+ layout.WithBottomPanel(editorContainer),
+ layout.WithFixedBottomHeight(3),
+ layout.WithFixedRightWidth(31),
+ ),
+ }
+}
@@ -0,0 +1 @@
+package chat
@@ -0,0 +1,44 @@
+package styles
+
+import (
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/charmtone"
+)
+
+func NewCrushTheme() *Theme {
+ return &Theme{
+ Name: "crush",
+ IsDark: true,
+
+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
+
+ // Backgrounds
+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
+
+ // Foregrounds
+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
+
+ // Borders
+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
+
+ // Status
+ Success: lipgloss.Color(charmtone.Guac.Hex()),
+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
+
+ // TODO: fix this.
+ SyntaxBg: lipgloss.Color("#1C1C1F"),
+ SyntaxKeyword: lipgloss.Color("#FF6DFE"),
+ SyntaxString: lipgloss.Color("#E8FE96"),
+ SyntaxComment: lipgloss.Color("#6B6F85"),
+ }
+}
@@ -0,0 +1,229 @@
+package styles
+
+import (
+ "fmt"
+ "image/color"
+
+ "github.com/charmbracelet/bubbles/v2/textarea"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+type Theme struct {
+ Name string
+ IsDark bool
+
+ Primary color.Color
+ Secondary color.Color
+ Tertiary color.Color
+ Accent color.Color
+
+ BgBase color.Color
+ BgSubtle color.Color
+ BgOverlay color.Color
+
+ FgBase color.Color
+ FgMuted color.Color
+ FgSubtle color.Color
+
+ Border color.Color
+ BorderFocus color.Color
+
+ Success color.Color
+ Error color.Color
+ Warning color.Color
+ Info color.Color
+
+ // TODO: add more syntax colors, maybe just use a chroma theme here.
+ SyntaxBg color.Color
+ SyntaxKeyword color.Color
+ SyntaxString color.Color
+ SyntaxComment color.Color
+
+ styles *Styles
+}
+
+type Styles struct {
+ Base lipgloss.Style
+
+ Title lipgloss.Style
+ Subtitle lipgloss.Style
+ Text lipgloss.Style
+ Muted lipgloss.Style
+ Subtle lipgloss.Style
+
+ Success lipgloss.Style
+ Error lipgloss.Style
+ Warning lipgloss.Style
+ Info lipgloss.Style
+
+ // Inputs
+ TextArea textarea.Styles
+}
+
+func (t *Theme) S() *Styles {
+ if t.styles == nil {
+ t.styles = t.buildStyles()
+ }
+ return t.styles
+}
+
+func (t *Theme) buildStyles() *Styles {
+ base := lipgloss.NewStyle().
+ Background(t.BgBase).
+ Foreground(t.FgBase)
+ return &Styles{
+ Base: base,
+
+ Title: base.
+ Foreground(t.Accent).
+ Bold(true),
+
+ Subtitle: base.
+ Foreground(t.Secondary).
+ Bold(true),
+
+ Text: base,
+
+ Muted: base.Foreground(t.FgMuted),
+
+ Subtle: base.Foreground(t.FgSubtle),
+
+ Success: base.Foreground(t.Success),
+
+ Error: base.Foreground(t.Error),
+
+ Warning: base.Foreground(t.Warning),
+
+ Info: base.Foreground(t.Info),
+
+ TextArea: textarea.Styles{
+ Focused: textarea.StyleState{
+ Base: base,
+ Text: base,
+ LineNumber: base.Foreground(t.FgSubtle),
+ CursorLine: base,
+ CursorLineNumber: base.Foreground(t.FgSubtle),
+ Placeholder: base.Foreground(t.FgMuted),
+ Prompt: base.Foreground(t.Tertiary),
+ },
+ Blurred: textarea.StyleState{
+ Base: base,
+ Text: base.Foreground(t.FgMuted),
+ LineNumber: base.Foreground(t.FgMuted),
+ CursorLine: base,
+ CursorLineNumber: base.Foreground(t.FgMuted),
+ Placeholder: base.Foreground(t.FgMuted),
+ Prompt: base.Foreground(t.FgMuted),
+ },
+ Cursor: textarea.CursorStyle{
+ Color: t.Secondary,
+ Shape: tea.CursorBar,
+ Blink: true,
+ },
+ },
+ }
+}
+
+type Manager struct {
+ themes map[string]*Theme
+ current *Theme
+}
+
+var defaultManager *Manager
+
+func SetDefaultManager(m *Manager) {
+ defaultManager = m
+}
+
+func DefaultManager() *Manager {
+ if defaultManager == nil {
+ defaultManager = NewManager("crush")
+ }
+ return defaultManager
+}
+
+func CurrentTheme() *Theme {
+ if defaultManager == nil {
+ defaultManager = NewManager("crush")
+ }
+ return defaultManager.Current()
+}
+
+func NewManager(defaultTheme string) *Manager {
+ m := &Manager{
+ themes: make(map[string]*Theme),
+ }
+
+ m.Register(NewCrushTheme())
+
+ m.current = m.themes[defaultTheme]
+
+ return m
+}
+
+func (m *Manager) Register(theme *Theme) {
+ m.themes[theme.Name] = theme
+}
+
+func (m *Manager) Current() *Theme {
+ return m.current
+}
+
+func (m *Manager) SetTheme(name string) error {
+ if theme, ok := m.themes[name]; ok {
+ m.current = theme
+ return nil
+ }
+ return fmt.Errorf("theme %s not found", name)
+}
+
+func (m *Manager) List() []string {
+ names := make([]string, 0, len(m.themes))
+ for name := range m.themes {
+ names = append(names, name)
+ }
+ return names
+}
+
+// ParseHex converts hex string to color
+func ParseHex(hex string) color.Color {
+ var r, g, b uint8
+ fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
+ return color.RGBA{R: r, G: g, B: b, A: 255}
+}
+
+// Alpha returns a color with transparency
+func Alpha(c color.Color, alpha uint8) color.Color {
+ r, g, b, _ := c.RGBA()
+ return color.RGBA{
+ R: uint8(r >> 8),
+ G: uint8(g >> 8),
+ B: uint8(b >> 8),
+ A: alpha,
+ }
+}
+
+// Darken makes a color darker by percentage (0-100)
+func Darken(c color.Color, percent float64) color.Color {
+ r, g, b, a := c.RGBA()
+ factor := 1.0 - percent/100.0
+ return color.RGBA{
+ R: uint8(float64(r>>8) * factor),
+ G: uint8(float64(g>>8) * factor),
+ B: uint8(float64(b>>8) * factor),
+ A: uint8(a >> 8),
+ }
+}
+
+// Lighten makes a color lighter by percentage (0-100)
+func Lighten(c color.Color, percent float64) color.Color {
+ r, g, b, a := c.RGBA()
+ factor := percent / 100.0
+ return color.RGBA{
+ R: uint8(min(255, float64(r>>8)+255*factor)),
+ G: uint8(min(255, float64(g>>8)+255*factor)),
+ B: uint8(min(255, float64(b>>8)+255*factor)),
+ A: uint8(a >> 8),
+ }
+}
@@ -14,10 +14,12 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/page"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/page/chat"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
+// appModel represents the main application model that manages pages, dialogs, and UI state.
type appModel struct {
width, height int
keyMap KeyMap
@@ -35,6 +37,7 @@ type appModel struct {
completions completions.Completions
}
+// Init initializes the application model and returns initial commands.
func (a appModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmd := a.pages[a.currentPage].Init()
@@ -46,6 +49,7 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
+// Update handles incoming messages and updates the application state.
func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
@@ -111,6 +115,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
+// handleWindowResize processes window resize events and updates all components.
func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
var cmds []tea.Cmd
msg.Height -= 1 // Make space for the status bar
@@ -134,6 +139,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
return tea.Batch(cmds...)
}
+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
switch {
// completions
@@ -182,11 +188,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
}
-// RegisterCommand adds a command to the command dialog
-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
-// a.commands = append(a.commands, cmd)
-// }
-
+// moveToPage handles navigation between different pages in the application.
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
if a.app.CoderAgent.IsBusy() {
// For now we don't move to any page if the agent is busy
@@ -209,6 +211,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
return tea.Batch(cmds...)
}
+// View renders the complete application interface including pages, dialogs, and overlays.
func (a *appModel) View() tea.View {
pageView := a.pages[a.currentPage].View()
components := []string{
@@ -220,7 +223,6 @@ func (a *appModel) View() tea.View {
layers := []*lipgloss.Layer{
lipgloss.NewLayer(appView),
}
- t := theme.CurrentTheme()
if a.dialog.HasDialogs() {
layers = append(
layers,
@@ -246,12 +248,15 @@ func (a *appModel) View() tea.View {
canvas := lipgloss.NewCanvas(
layers...,
)
+
+ t := styles.CurrentTheme()
view := tea.NewView(canvas.Render())
- view.SetBackgroundColor(t.Background())
+ view.SetBackgroundColor(t.BgBase)
view.SetCursor(cursor)
return view
}
+// New creates and initializes a new TUI application model.
func New(app *app.App) tea.Model {
startPage := page.ChatPage
model := &appModel{
@@ -262,7 +267,7 @@ func New(app *app.App) tea.Model {
keyMap: DefaultKeyMap(),
pages: map[page.PageID]util.Model{
- page.ChatPage: page.NewChatPage(app),
+ page.ChatPage: chat.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
@@ -0,0 +1,8 @@
+# Chat Page
+
+## Landing page
+
+- [ ] Implement the logo landing page
+- [ ] Add cwd improved
+- [ ] Implement Active LSPs
+- [ ] Implement Active MCPs