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