on main help sign in
wip theme + new UI
commitaad40903526ec14c0d9ae355514d4c7aff1eed3d authorKujtim Hoxha <kujtimii.h@gmail.com> dateSun 1 Jun 2025, 01:04 (1 year ago) parentb37ff5f
18 files changed+938−68
.opencode.json+2−2 cspell.json+1−1 diff.diff+124 go.mod+1 go.sum+2 internal/tui/components/chat/chat.go+1−1 internal/tui/components/chat/editor/editor.go+23−41 internal/tui/components/chat/sidebar/sidebar.go+205 internal/tui/components/core/helpers.go+63 internal/tui/components/logo/logo.go+1−1 internal/tui/layout/container.go+4−5 internal/tui/layout/split.go+42−8 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+14−9 todos.md+8
.opencode.json+2−2
@@ -1,11 +1,11 @@
11{
22 "$schema": "./opencode-schema.json",
33 "lsp": {
4− "gopls": {
4+ "Go": {
55 "command": "gopls"
66 }
77 },
88 "tui": {
9− "theme": "opencode-dark"
9+ "theme": "charm"
1010 }
1111}
cspell.json+1−1
@@ -1 +1 @@
1−{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
1+{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps"],"version":"0.2","language":"en","flagWords":[]}
diff.diffnew file+124
@@ -0,0 +1,124 @@
1+diff --git a/.opencode.json b/.opencode.json
2+index 75e357d..59be1e8 100644
3+--- a/.opencode.json
4++++ b/.opencode.json
5+@@ -6,6 +6,6 @@
6+ }
7+ },
8+ "tui": {
9+- "theme": "opencode-dark"
10++ "theme": "charm"
11+ }
12+ }
13+diff --git a/go.mod b/go.mod
14+index 18ad042..940a8e8 100644
15+--- a/go.mod
16++++ b/go.mod
17+@@ -36,6 +36,8 @@ require (
18+ github.com/stretchr/testify v1.10.0
19+ )
20+
21++require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 // indirect
22++
23+ require (
24+ cloud.google.com/go v0.116.0 // indirect
25+ cloud.google.com/go/auth v0.13.0 // indirect
26+diff --git a/go.sum b/go.sum
27+index f6e08b7..8f347ed 100644
28+--- a/go.sum
29++++ b/go.sum
30+@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
31+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
32+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
33+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
34++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
35++github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
36+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
37+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
38+ github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
39+diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
40+index 2ee0b04..52c4dae 100644
41+--- a/internal/tui/components/chat/chat.go
42++++ b/internal/tui/components/chat/chat.go
43+@@ -95,7 +95,7 @@ func lspsConfigured() string {
44+ func logoBlock() string {
45+ t := theme.CurrentTheme()
46+ return logo.Render(version.Version, true, logo.Opts{
47+- FieldColor: t.Accent(),
48++ FieldColor: t.Secondary(),
49+ TitleColorA: t.Primary(),
50+ TitleColorB: t.Secondary(),
51+ CharmColor: t.Primary(),
52+diff --git a/internal/tui/tui.go b/internal/tui/tui.go
53+index 9e8a62a..3f07956 100644
54+--- a/internal/tui/tui.go
55++++ b/internal/tui/tui.go
56+@@ -18,6 +18,7 @@ import (
57+ "github.com/opencode-ai/opencode/internal/tui/util"
58+ )
59+
60++// appModel represents the main application model that manages pages, dialogs, and UI state.
61+ type appModel struct {
62+ width, height int
63+ keyMap KeyMap
64+@@ -35,6 +36,7 @@ type appModel struct {
65+ completions completions.Completions
66+ }
67+
68++// Init initializes the application model and returns initial commands.
69+ func (a appModel) Init() tea.Cmd {
70+ var cmds []tea.Cmd
71+ cmd := a.pages[a.currentPage].Init()
72+@@ -46,6 +48,7 @@ func (a appModel) Init() tea.Cmd {
73+ return tea.Batch(cmds...)
74+ }
75+
76++// Update handles incoming messages and updates the application state.
77+ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78+ var cmds []tea.Cmd
79+ var cmd tea.Cmd
80+@@ -111,6 +114,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81+ return a, tea.Batch(cmds...)
82+ }
83+
84++// handleWindowResize processes window resize events and updates all components.
85+ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
86+ var cmds []tea.Cmd
87+ msg.Height -= 1 // Make space for the status bar
88+@@ -134,6 +138,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
89+ return tea.Batch(cmds...)
90+ }
91+
92++// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
93+ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
94+ switch {
95+ // completions
96+@@ -182,11 +187,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
97+ }
98+ }
99+
100+-// RegisterCommand adds a command to the command dialog
101+-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
102+-// a.commands = append(a.commands, cmd)
103+-// }
104+-
105++// moveToPage handles navigation between different pages in the application.
106+ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
107+ if a.app.CoderAgent.IsBusy() {
108+ // For now we don't move to any page if the agent is busy
109+@@ -209,6 +210,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
110+ return tea.Batch(cmds...)
111+ }
112+
113++// View renders the complete application interface including pages, dialogs, and overlays.
114+ func (a *appModel) View() tea.View {
115+ pageView := a.pages[a.currentPage].View()
116+ components := []string{
117+@@ -252,6 +254,7 @@ func (a *appModel) View() tea.View {
118+ return view
119+ }
120+
121++// New creates and initializes a new TUI application model.
122+ func New(app *app.App) tea.Model {
123+ startPage := page.ChatPage
124+ model := &appModel{
go.mod+1
@@ -18,6 +18,7 @@ require (
1818 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1919 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
2020 github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa
21+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
2122 github.com/fsnotify/fsnotify v1.8.0
2223 github.com/go-logfmt/logfmt v0.6.0
2324 github.com/google/uuid v1.6.0
go.sum+2
@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
8484github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
8585github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
8686github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
87+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
88+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
8789github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
8890github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
8991github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
internal/tui/components/chat/chat.go+1−1
@@ -95,7 +95,7 @@ func lspsConfigured() string {
9595func logoBlock() string {
9696 t := theme.CurrentTheme()
9797 return logo.Render(version.Version, true, logo.Opts{
98− FieldColor: t.Accent(),
98+ FieldColor: t.Secondary(),
9999 TitleColorA: t.Primary(),
100100 TitleColorB: t.Secondary(),
101101 CharmColor: t.Primary(),
internal/tui/components/chat/editor/editor.go+23−41
@@ -261,29 +261,25 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261261}
262262
263263func (m *editorCmp) View() tea.View {
264− t := theme.CurrentTheme()
265−
266− // Style the prompt with theme colors
267− style := lipgloss.NewStyle().
268− Padding(0, 0, 0, 1).
269− Bold(true).
270− Foreground(t.Primary())
271−
264+ t := styles.CurrentTheme()
272265 cursor := m.textarea.Cursor()
273− cursor.X = cursor.X + m.x + 2
274− cursor.Y = cursor.Y + m.y + 1
266+ cursor.X = cursor.X + m.x + 1
267+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
275268 if len(m.attachments) == 0 {
276− view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
269+ content := t.S().Base.Padding(1).Render(
270+ m.textarea.View(),
271+ )
272+ view := tea.NewView(content)
277273 view.SetCursor(cursor)
278274 return view
279275 }
280− m.textarea.SetHeight(m.height - 1)
281− view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
282− m.attachmentsContent(),
283− lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
276+ content := t.S().Base.Padding(0, 1, 1, 1).Render(
277+ lipgloss.JoinVertical(lipgloss.Top,
278+ m.attachmentsContent(),
284279 m.textarea.View(),
285280 ),
286− ))
281+ )
282+ view := tea.NewView(content)
287283 view.SetCursor(cursor)
288284 return view
289285}
@@ -291,8 +287,8 @@ func (m *editorCmp) View() tea.View {
291287func (m *editorCmp) SetSize(width, height int) tea.Cmd {
292288 m.width = width
293289 m.height = height
294− m.textarea.SetWidth(width - 3) // account for the prompt and padding right
295− m.textarea.SetHeight(height)
290+ m.textarea.SetWidth(width - 2) // adjust for padding
291+ m.textarea.SetHeight(height - 2) // adjust for padding
296292 return nil
297293}
298294
@@ -359,32 +355,18 @@ func (m *editorCmp) startCompletions() tea.Msg {
359355}
360356
361357func CreateTextArea(existing *textarea.Model) textarea.Model {
362− t := theme.CurrentTheme()
363− bgColor := t.Background()
364− textColor := t.Text()
365− textMutedColor := t.TextMuted()
366−
358+ t := styles.CurrentTheme()
367359 ta := textarea.New()
368− s := textarea.DefaultDarkStyles()
369− b := s.Blurred
370− b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
371− b.CursorLine = styles.BaseStyle().Background(bgColor)
372− b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
373− b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
374−
375− f := s.Focused
376− f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
377− f.CursorLine = styles.BaseStyle().Background(bgColor)
378− f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
379− f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
380−
381− s.Focused = f
382− s.Blurred = b
383− ta.SetStyles(s)
384−
385− ta.Prompt = " "
360+ ta.SetStyles(t.S().TextArea)
361+ ta.SetPromptFunc(2, func(lineIndex int) string {
362+ if lineIndex == 0 {
363+ return "> "
364+ }
365+ return t.S().Muted.Render(": ")
366+ })
386367 ta.ShowLineNumbers = false
387368 ta.CharLimit = -1
369+ ta.Placeholder = "Tell me more about this project..."
388370 ta.SetVirtualCursor(false)
389371
390372 if existing != nil {
internal/tui/components/chat/sidebar/sidebar.gonew file+205
@@ -0,0 +1,205 @@
1+package sidebar
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/lipgloss/v2"
9+ "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/opencode-ai/opencode/internal/pubsub"
11+ "github.com/opencode-ai/opencode/internal/session"
12+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
13+ "github.com/opencode-ai/opencode/internal/tui/components/core"
14+ "github.com/opencode-ai/opencode/internal/tui/components/logo"
15+ "github.com/opencode-ai/opencode/internal/tui/layout"
16+ "github.com/opencode-ai/opencode/internal/tui/styles"
17+ "github.com/opencode-ai/opencode/internal/tui/util"
18+ "github.com/opencode-ai/opencode/internal/version"
19+)
20+
21+const (
22+ logoBreakpoint = 65
23+)
24+
25+type Sidebar interface {
26+ util.Model
27+ layout.Sizeable
28+}
29+
30+type sidebarCmp struct {
31+ width, height int
32+ session session.Session
33+ logo string
34+ cwd string
35+}
36+
37+func NewSidebarCmp() Sidebar {
38+ return &sidebarCmp{}
39+}
40+
41+func (m *sidebarCmp) Init() tea.Cmd {
42+ m.logo = m.logoBlock(false)
43+ m.cwd = cwd()
44+ return nil
45+}
46+
47+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48+ switch msg := msg.(type) {
49+ case chat.SessionSelectedMsg:
50+ if msg.ID != m.session.ID {
51+ m.session = msg
52+ }
53+ case pubsub.Event[session.Session]:
54+ if msg.Type == pubsub.UpdatedEvent {
55+ if m.session.ID == msg.Payload.ID {
56+ m.session = msg.Payload
57+ }
58+ }
59+ }
60+ return m, nil
61+}
62+
63+func (m *sidebarCmp) View() tea.View {
64+ t := styles.CurrentTheme()
65+ parts := []string{
66+ m.logo,
67+ }
68+
69+ if m.session.ID != "" {
70+ parts = append(parts, t.S().Muted.Render(m.session.Title), "")
71+ }
72+
73+ parts = append(parts,
74+ m.cwd,
75+ "",
76+ m.lspBlock(),
77+ "",
78+ m.mcpBlock(),
79+ )
80+
81+ return tea.NewView(
82+ lipgloss.JoinVertical(lipgloss.Left, parts...),
83+ )
84+}
85+
86+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
87+ if width < logoBreakpoint && m.width >= logoBreakpoint {
88+ m.logo = m.logoBlock(true)
89+ } else if width >= logoBreakpoint && m.width < logoBreakpoint {
90+ m.logo = m.logoBlock(false)
91+ }
92+
93+ m.width = width
94+ m.height = height
95+ return nil
96+}
97+
98+func (m *sidebarCmp) GetSize() (int, int) {
99+ return m.width, m.height
100+}
101+
102+func (m *sidebarCmp) logoBlock(compact bool) string {
103+ t := styles.CurrentTheme()
104+ return logo.Render(version.Version, compact, logo.Opts{
105+ FieldColor: t.Primary,
106+ TitleColorA: t.Secondary,
107+ TitleColorB: t.Primary,
108+ CharmColor: t.Secondary,
109+ VersionColor: t.Primary,
110+ })
111+}
112+
113+func (m *sidebarCmp) lspBlock() string {
114+ maxWidth := min(m.width, 58)
115+ t := styles.CurrentTheme()
116+
117+ section := t.S().Muted.Render(
118+ core.Section("Configured LSPs", maxWidth),
119+ )
120+
121+ lspList := []string{section, ""}
122+
123+ lsp := config.Get().LSP
124+ if len(lsp) == 0 {
125+ return lipgloss.JoinVertical(
126+ lipgloss.Left,
127+ section,
128+ "",
129+ t.S().Muted.Render("No LSPs configured."),
130+ )
131+ }
132+
133+ for n, l := range lsp {
134+ iconColor := t.Success
135+ if l.Disabled {
136+ iconColor = t.FgMuted
137+ }
138+ lspList = append(lspList,
139+ core.Status(
140+ core.StatusOpts{
141+ IconColor: iconColor,
142+ Title: n,
143+ Description: l.Command,
144+ },
145+ m.width,
146+ ),
147+ )
148+ }
149+
150+ return lipgloss.JoinVertical(
151+ lipgloss.Left,
152+ lspList...,
153+ )
154+}
155+
156+func (m *sidebarCmp) mcpBlock() string {
157+ maxWidth := min(m.width, 58)
158+ t := styles.CurrentTheme()
159+
160+ section := t.S().Muted.Render(
161+ core.Section("Configured MCPs", maxWidth),
162+ )
163+
164+ mcpList := []string{section, ""}
165+
166+ mcp := config.Get().MCPServers
167+ if len(mcp) == 0 {
168+ return lipgloss.JoinVertical(
169+ lipgloss.Left,
170+ section,
171+ "",
172+ t.S().Muted.Render("No MCPs configured."),
173+ )
174+ }
175+
176+ for n, l := range mcp {
177+ iconColor := t.Success
178+ mcpList = append(mcpList,
179+ core.Status(
180+ core.StatusOpts{
181+ IconColor: iconColor,
182+ Title: n,
183+ Description: l.Command,
184+ },
185+ m.width,
186+ ),
187+ )
188+ }
189+
190+ return lipgloss.JoinVertical(
191+ lipgloss.Left,
192+ mcpList...,
193+ )
194+}
195+
196+func cwd() string {
197+ cwd := config.WorkingDirectory()
198+ t := styles.CurrentTheme()
199+ // replace home directory with ~
200+ homeDir, err := os.UserHomeDir()
201+ if err == nil {
202+ cwd = strings.ReplaceAll(cwd, homeDir, "~")
203+ }
204+ return t.S().Muted.Render(cwd)
205+}
internal/tui/components/core/helpers.gonew file+63
@@ -0,0 +1,63 @@
1+package core
2+
3+import (
4+ "image/color"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss/v2"
8+ "github.com/charmbracelet/x/ansi"
9+ "github.com/opencode-ai/opencode/internal/tui/styles"
10+)
11+
12+func Section(title string, width int) string {
13+ t := styles.CurrentTheme()
14+ char := "─"
15+ length := len(title) + 1
16+ remainingWidth := width - length
17+ if remainingWidth > 0 {
18+ title = title + " " + t.S().Subtle.Render(strings.Repeat(char, remainingWidth))
19+ }
20+ return title
21+}
22+
23+type StatusOpts struct {
24+ Icon string
25+ IconColor color.Color
26+ Title string
27+ TitleColor color.Color
28+ Description string
29+ DescriptionColor color.Color
30+}
31+
32+func Status(ops StatusOpts, width int) string {
33+ t := styles.CurrentTheme()
34+ icon := "●"
35+ iconColor := t.Success
36+ if ops.Icon != "" {
37+ icon = ops.Icon
38+ }
39+ if ops.IconColor != nil {
40+ iconColor = ops.IconColor
41+ }
42+ title := ops.Title
43+ titleColor := t.FgMuted
44+ if ops.TitleColor != nil {
45+ titleColor = ops.TitleColor
46+ }
47+ description := ops.Description
48+ descriptionColor := t.FgSubtle
49+ if ops.DescriptionColor != nil {
50+ descriptionColor = ops.DescriptionColor
51+ }
52+ icon = t.S().Base.Foreground(iconColor).Render(icon)
53+ title = t.S().Base.Foreground(titleColor).Render(title)
54+ if description != "" {
55+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
56+ }
57+ description = t.S().Base.Foreground(descriptionColor).Render(description)
58+ return strings.Join([]string{
59+ icon,
60+ title,
61+ description,
62+ }, " ")
63+}
internal/tui/components/logo/logo.go+1−1
@@ -63,7 +63,7 @@ func Render(version string, compact bool, o Opts) string {
6363 // Narrow version.
6464 if compact {
6565 field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
66− return strings.Join([]string{field, field, crush, field}, "\n")
66+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
6767 }
6868
6969 fieldHeight := lipgloss.Height(crush)
internal/tui/layout/container.go+4−5
@@ -4,7 +4,7 @@ import (
44 "github.com/charmbracelet/bubbles/v2/key"
55 tea "github.com/charmbracelet/bubbletea/v2"
66 "github.com/charmbracelet/lipgloss/v2"
7− "github.com/opencode-ai/opencode/internal/tui/theme"
7+ "github.com/opencode-ai/opencode/internal/tui/styles"
88 "github.com/opencode-ai/opencode/internal/tui/util"
99)
1010
@@ -46,12 +46,11 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4646}
4747
4848func (c *container) View() tea.View {
49− t := theme.CurrentTheme()
50− style := lipgloss.NewStyle()
49+ t := styles.CurrentTheme()
5150 width := c.width
5251 height := c.height
5352
54− style = style.Background(t.Background())
53+ style := t.S().Base
5554
5655 // Apply border if any side is enabled
5756 if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -69,7 +68,7 @@ func (c *container) View() tea.View {
6968 width--
7069 }
7170 style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
72− style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
71+ style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
7372 }
7473 style = style.
7574 Width(width).
internal/tui/layout/split.go+42−8
@@ -22,14 +22,18 @@ type SplitPaneLayout interface {
2222}
2323
2424type splitPaneLayout struct {
25− width int
26− height int
25+ width int
26+ height int
27+
2728 ratio float64
2829 verticalRatio float64
2930
3031 rightPanel Container
3132 leftPanel Container
3233 bottomPanel Container
34+
35+ fixedBottomHeight int // Fixed height for the bottom panel, if any
36+ fixedRightWidth int // Fixed width for the right panel, if any
3337}
3438
3539type SplitPaneOption func(*splitPaneLayout)
@@ -141,8 +145,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
141145 var topHeight, bottomHeight int
142146 var cmds []tea.Cmd
143147 if s.bottomPanel != nil {
144− topHeight = int(float64(height) * s.verticalRatio)
145− bottomHeight = height - topHeight
148+ if s.fixedBottomHeight > 0 {
149+ bottomHeight = s.fixedBottomHeight
150+ topHeight = height - bottomHeight
151+ } else {
152+ topHeight = int(float64(height) * s.verticalRatio)
153+ bottomHeight = height - topHeight
154+ if bottomHeight <= 0 {
155+ bottomHeight = 2
156+ topHeight = height - bottomHeight
157+ }
158+ }
146159 } else {
147160 topHeight = height
148161 bottomHeight = 0
@@ -150,8 +163,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
150163
151164 var leftWidth, rightWidth int
152165 if s.leftPanel != nil && s.rightPanel != nil {
153− leftWidth = int(float64(width) * s.ratio)
154− rightWidth = width - leftWidth
166+ if s.fixedRightWidth > 0 {
167+ rightWidth = s.fixedRightWidth
168+ leftWidth = width - rightWidth
169+ } else {
170+ leftWidth = int(float64(width) * s.ratio)
171+ rightWidth = width - leftWidth
172+ if rightWidth <= 0 {
173+ rightWidth = 2
174+ leftWidth = width - rightWidth
175+ }
176+ }
155177 } else if s.leftPanel != nil {
156178 leftWidth = width
157179 rightWidth = 0
@@ -260,8 +282,8 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
260282
261283func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
262284 layout := &splitPaneLayout{
263− ratio: 0.7,
264− verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
285+ ratio: 0.8,
286+ verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
265287 }
266288 for _, option := range options {
267289 option(layout)
@@ -298,3 +320,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
298320 s.verticalRatio = ratio
299321 }
300322}
323+
324+func WithFixedBottomHeight(height int) SplitPaneOption {
325+ return func(s *splitPaneLayout) {
326+ s.fixedBottomHeight = height
327+ }
328+}
329+
330+func WithFixedRightWidth(width int) SplitPaneOption {
331+ return func(s *splitPaneLayout) {
332+ s.fixedRightWidth = width
333+ }
334+}
internal/tui/page/chat/chat.gonew file+173
@@ -0,0 +1,173 @@
1+package chat
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/opencode-ai/opencode/internal/app"
9+ "github.com/opencode-ai/opencode/internal/message"
10+ "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
12+ "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
13+ "github.com/opencode-ai/opencode/internal/tui/components/chat/sidebar"
14+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
15+ "github.com/opencode-ai/opencode/internal/tui/layout"
16+ "github.com/opencode-ai/opencode/internal/tui/page"
17+ "github.com/opencode-ai/opencode/internal/tui/util"
18+)
19+
20+var ChatPage page.PageID = "chat"
21+
22+type chatPage struct {
23+ app *app.App
24+
25+ layout layout.SplitPaneLayout
26+
27+ session session.Session
28+}
29+
30+type ChatKeyMap struct {
31+ NewSession key.Binding
32+ Cancel key.Binding
33+}
34+
35+var keyMap = ChatKeyMap{
36+ NewSession: key.NewBinding(
37+ key.WithKeys("ctrl+n"),
38+ key.WithHelp("ctrl+n", "new session"),
39+ ),
40+ Cancel: key.NewBinding(
41+ key.WithKeys("esc"),
42+ key.WithHelp("esc", "cancel"),
43+ ),
44+}
45+
46+func (p *chatPage) Init() tea.Cmd {
47+ return p.layout.Init()
48+}
49+
50+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
51+ var cmds []tea.Cmd
52+ switch msg := msg.(type) {
53+ case tea.WindowSizeMsg:
54+ cmd := p.layout.SetSize(msg.Width, msg.Height)
55+ cmds = append(cmds, cmd)
56+ case chat.SendMsg:
57+ cmd := p.sendMessage(msg.Text, msg.Attachments)
58+ if cmd != nil {
59+ return p, cmd
60+ }
61+ case commands.CommandRunCustomMsg:
62+ // Check if the agent is busy before executing custom commands
63+ if p.app.CoderAgent.IsBusy() {
64+ return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
65+ }
66+
67+ // Handle custom command execution
68+ cmd := p.sendMessage(msg.Content, nil)
69+ if cmd != nil {
70+ return p, cmd
71+ }
72+ case chat.SessionSelectedMsg:
73+ if p.session.ID == "" {
74+ cmd := p.setMessages()
75+ if cmd != nil {
76+ cmds = append(cmds, cmd)
77+ }
78+ }
79+ p.session = msg
80+ case tea.KeyPressMsg:
81+ switch {
82+ case key.Matches(msg, keyMap.NewSession):
83+ p.session = session.Session{}
84+ return p, tea.Batch(
85+ p.clearMessages(),
86+ util.CmdHandler(chat.SessionClearedMsg{}),
87+ )
88+ case key.Matches(msg, keyMap.Cancel):
89+ if p.session.ID != "" {
90+ // Cancel the current session's generation process
91+ // This allows users to interrupt long-running operations
92+ p.app.CoderAgent.Cancel(p.session.ID)
93+ return p, nil
94+ }
95+ }
96+ }
97+ u, cmd := p.layout.Update(msg)
98+ cmds = append(cmds, cmd)
99+ p.layout = u.(layout.SplitPaneLayout)
100+
101+ return p, tea.Batch(cmds...)
102+}
103+
104+func (p *chatPage) setMessages() tea.Cmd {
105+ messagesContainer := layout.NewContainer(
106+ chat.NewMessagesListCmp(p.app),
107+ layout.WithPadding(1, 1, 0, 1),
108+ )
109+ return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
110+}
111+
112+func (p *chatPage) clearMessages() tea.Cmd {
113+ return p.layout.ClearLeftPanel()
114+}
115+
116+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
117+ var cmds []tea.Cmd
118+ if p.session.ID == "" {
119+ session, err := p.app.Sessions.Create(context.Background(), "New Session")
120+ if err != nil {
121+ return util.ReportError(err)
122+ }
123+
124+ p.session = session
125+ cmd := p.setMessages()
126+ if cmd != nil {
127+ cmds = append(cmds, cmd)
128+ }
129+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
130+ }
131+
132+ _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
133+ if err != nil {
134+ return util.ReportError(err)
135+ }
136+ return tea.Batch(cmds...)
137+}
138+
139+func (p *chatPage) SetSize(width, height int) tea.Cmd {
140+ return p.layout.SetSize(width, height)
141+}
142+
143+func (p *chatPage) GetSize() (int, int) {
144+ return p.layout.GetSize()
145+}
146+
147+func (p *chatPage) View() tea.View {
148+ return p.layout.View()
149+}
150+
151+func (p *chatPage) BindingKeys() []key.Binding {
152+ bindings := layout.KeyMapToSlice(keyMap)
153+ return bindings
154+}
155+
156+func NewChatPage(app *app.App) util.Model {
157+ sidebarContainer := layout.NewContainer(
158+ sidebar.NewSidebarCmp(),
159+ layout.WithPadding(1, 1, 1, 1),
160+ )
161+ editorContainer := layout.NewContainer(
162+ editor.NewEditorCmp(app),
163+ )
164+ return &chatPage{
165+ app: app,
166+ layout: layout.NewSplitPane(
167+ layout.WithRightPanel(sidebarContainer),
168+ layout.WithBottomPanel(editorContainer),
169+ layout.WithFixedBottomHeight(3),
170+ layout.WithFixedRightWidth(31),
171+ ),
172+ }
173+}
internal/tui/page/chat/keys.gonew file+1
@@ -0,0 +1 @@
1+package chat
internal/tui/styles/crush.gonew file+44
@@ -0,0 +1,44 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/lipgloss/v2"
5+ "github.com/charmbracelet/x/exp/charmtone"
6+)
7+
8+func NewCrushTheme() *Theme {
9+ return &Theme{
10+ Name: "crush",
11+ IsDark: true,
12+
13+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
14+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
15+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
16+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
17+
18+ // Backgrounds
19+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
20+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
21+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
22+
23+ // Foregrounds
24+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
25+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
26+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
27+
28+ // Borders
29+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
30+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
31+
32+ // Status
33+ Success: lipgloss.Color(charmtone.Guac.Hex()),
34+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
35+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
36+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
37+
38+ // TODO: fix this.
39+ SyntaxBg: lipgloss.Color("#1C1C1F"),
40+ SyntaxKeyword: lipgloss.Color("#FF6DFE"),
41+ SyntaxString: lipgloss.Color("#E8FE96"),
42+ SyntaxComment: lipgloss.Color("#6B6F85"),
43+ }
44+}
internal/tui/styles/theme.gonew file+229
@@ -0,0 +1,229 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "image/color"
6+
7+ "github.com/charmbracelet/bubbles/v2/textarea"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/lipgloss/v2"
10+)
11+
12+type Theme struct {
13+ Name string
14+ IsDark bool
15+
16+ Primary color.Color
17+ Secondary color.Color
18+ Tertiary color.Color
19+ Accent color.Color
20+
21+ BgBase color.Color
22+ BgSubtle color.Color
23+ BgOverlay color.Color
24+
25+ FgBase color.Color
26+ FgMuted color.Color
27+ FgSubtle color.Color
28+
29+ Border color.Color
30+ BorderFocus color.Color
31+
32+ Success color.Color
33+ Error color.Color
34+ Warning color.Color
35+ Info color.Color
36+
37+ // TODO: add more syntax colors, maybe just use a chroma theme here.
38+ SyntaxBg color.Color
39+ SyntaxKeyword color.Color
40+ SyntaxString color.Color
41+ SyntaxComment color.Color
42+
43+ styles *Styles
44+}
45+
46+type Styles struct {
47+ Base lipgloss.Style
48+
49+ Title lipgloss.Style
50+ Subtitle lipgloss.Style
51+ Text lipgloss.Style
52+ Muted lipgloss.Style
53+ Subtle lipgloss.Style
54+
55+ Success lipgloss.Style
56+ Error lipgloss.Style
57+ Warning lipgloss.Style
58+ Info lipgloss.Style
59+
60+ // Inputs
61+ TextArea textarea.Styles
62+}
63+
64+func (t *Theme) S() *Styles {
65+ if t.styles == nil {
66+ t.styles = t.buildStyles()
67+ }
68+ return t.styles
69+}
70+
71+func (t *Theme) buildStyles() *Styles {
72+ base := lipgloss.NewStyle().
73+ Background(t.BgBase).
74+ Foreground(t.FgBase)
75+ return &Styles{
76+ Base: base,
77+
78+ Title: base.
79+ Foreground(t.Accent).
80+ Bold(true),
81+
82+ Subtitle: base.
83+ Foreground(t.Secondary).
84+ Bold(true),
85+
86+ Text: base,
87+
88+ Muted: base.Foreground(t.FgMuted),
89+
90+ Subtle: base.Foreground(t.FgSubtle),
91+
92+ Success: base.Foreground(t.Success),
93+
94+ Error: base.Foreground(t.Error),
95+
96+ Warning: base.Foreground(t.Warning),
97+
98+ Info: base.Foreground(t.Info),
99+
100+ TextArea: textarea.Styles{
101+ Focused: textarea.StyleState{
102+ Base: base,
103+ Text: base,
104+ LineNumber: base.Foreground(t.FgSubtle),
105+ CursorLine: base,
106+ CursorLineNumber: base.Foreground(t.FgSubtle),
107+ Placeholder: base.Foreground(t.FgMuted),
108+ Prompt: base.Foreground(t.Tertiary),
109+ },
110+ Blurred: textarea.StyleState{
111+ Base: base,
112+ Text: base.Foreground(t.FgMuted),
113+ LineNumber: base.Foreground(t.FgMuted),
114+ CursorLine: base,
115+ CursorLineNumber: base.Foreground(t.FgMuted),
116+ Placeholder: base.Foreground(t.FgMuted),
117+ Prompt: base.Foreground(t.FgMuted),
118+ },
119+ Cursor: textarea.CursorStyle{
120+ Color: t.Secondary,
121+ Shape: tea.CursorBar,
122+ Blink: true,
123+ },
124+ },
125+ }
126+}
127+
128+type Manager struct {
129+ themes map[string]*Theme
130+ current *Theme
131+}
132+
133+var defaultManager *Manager
134+
135+func SetDefaultManager(m *Manager) {
136+ defaultManager = m
137+}
138+
139+func DefaultManager() *Manager {
140+ if defaultManager == nil {
141+ defaultManager = NewManager("crush")
142+ }
143+ return defaultManager
144+}
145+
146+func CurrentTheme() *Theme {
147+ if defaultManager == nil {
148+ defaultManager = NewManager("crush")
149+ }
150+ return defaultManager.Current()
151+}
152+
153+func NewManager(defaultTheme string) *Manager {
154+ m := &Manager{
155+ themes: make(map[string]*Theme),
156+ }
157+
158+ m.Register(NewCrushTheme())
159+
160+ m.current = m.themes[defaultTheme]
161+
162+ return m
163+}
164+
165+func (m *Manager) Register(theme *Theme) {
166+ m.themes[theme.Name] = theme
167+}
168+
169+func (m *Manager) Current() *Theme {
170+ return m.current
171+}
172+
173+func (m *Manager) SetTheme(name string) error {
174+ if theme, ok := m.themes[name]; ok {
175+ m.current = theme
176+ return nil
177+ }
178+ return fmt.Errorf("theme %s not found", name)
179+}
180+
181+func (m *Manager) List() []string {
182+ names := make([]string, 0, len(m.themes))
183+ for name := range m.themes {
184+ names = append(names, name)
185+ }
186+ return names
187+}
188+
189+// ParseHex converts hex string to color
190+func ParseHex(hex string) color.Color {
191+ var r, g, b uint8
192+ fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
193+ return color.RGBA{R: r, G: g, B: b, A: 255}
194+}
195+
196+// Alpha returns a color with transparency
197+func Alpha(c color.Color, alpha uint8) color.Color {
198+ r, g, b, _ := c.RGBA()
199+ return color.RGBA{
200+ R: uint8(r >> 8),
201+ G: uint8(g >> 8),
202+ B: uint8(b >> 8),
203+ A: alpha,
204+ }
205+}
206+
207+// Darken makes a color darker by percentage (0-100)
208+func Darken(c color.Color, percent float64) color.Color {
209+ r, g, b, a := c.RGBA()
210+ factor := 1.0 - percent/100.0
211+ return color.RGBA{
212+ R: uint8(float64(r>>8) * factor),
213+ G: uint8(float64(g>>8) * factor),
214+ B: uint8(float64(b>>8) * factor),
215+ A: uint8(a >> 8),
216+ }
217+}
218+
219+// Lighten makes a color lighter by percentage (0-100)
220+func Lighten(c color.Color, percent float64) color.Color {
221+ r, g, b, a := c.RGBA()
222+ factor := percent / 100.0
223+ return color.RGBA{
224+ R: uint8(min(255, float64(r>>8)+255*factor)),
225+ G: uint8(min(255, float64(g>>8)+255*factor)),
226+ B: uint8(min(255, float64(b>>8)+255*factor)),
227+ A: uint8(a >> 8),
228+ }
229+}
internal/tui/tui.go+14−9
@@ -14,10 +14,12 @@ import (
1414 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
1515 "github.com/opencode-ai/opencode/internal/tui/layout"
1616 "github.com/opencode-ai/opencode/internal/tui/page"
17− "github.com/opencode-ai/opencode/internal/tui/theme"
17+ "github.com/opencode-ai/opencode/internal/tui/page/chat"
18+ "github.com/opencode-ai/opencode/internal/tui/styles"
1819 "github.com/opencode-ai/opencode/internal/tui/util"
1920)
2021
22+// appModel represents the main application model that manages pages, dialogs, and UI state.
2123type appModel struct {
2224 width, height int
2325 keyMap KeyMap
@@ -35,6 +37,7 @@ type appModel struct {
3537 completions completions.Completions
3638}
3739
40+// Init initializes the application model and returns initial commands.
3841func (a appModel) Init() tea.Cmd {
3942 var cmds []tea.Cmd
4043 cmd := a.pages[a.currentPage].Init()
@@ -46,6 +49,7 @@ func (a appModel) Init() tea.Cmd {
4649 return tea.Batch(cmds...)
4750}
4851
52+// Update handles incoming messages and updates the application state.
4953func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5054 var cmds []tea.Cmd
5155 var cmd tea.Cmd
@@ -111,6 +115,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111115 return a, tea.Batch(cmds...)
112116}
113117
118+// handleWindowResize processes window resize events and updates all components.
114119func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
115120 var cmds []tea.Cmd
116121 msg.Height -= 1 // Make space for the status bar
@@ -134,6 +139,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
134139 return tea.Batch(cmds...)
135140}
136141
142+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
137143func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
138144 switch {
139145 // completions
@@ -182,11 +188,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
182188 }
183189}
184190
185−// RegisterCommand adds a command to the command dialog
186−// func (a *appModel) RegisterCommand(cmd dialog.Command) {
187−// a.commands = append(a.commands, cmd)
188−// }
189−
191+// moveToPage handles navigation between different pages in the application.
190192func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
191193 if a.app.CoderAgent.IsBusy() {
192194 // 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 {
209211 return tea.Batch(cmds...)
210212}
211213
214+// View renders the complete application interface including pages, dialogs, and overlays.
212215func (a *appModel) View() tea.View {
213216 pageView := a.pages[a.currentPage].View()
214217 components := []string{
@@ -220,7 +223,6 @@ func (a *appModel) View() tea.View {
220223 layers := []*lipgloss.Layer{
221224 lipgloss.NewLayer(appView),
222225 }
223− t := theme.CurrentTheme()
224226 if a.dialog.HasDialogs() {
225227 layers = append(
226228 layers,
@@ -246,12 +248,15 @@ func (a *appModel) View() tea.View {
246248 canvas := lipgloss.NewCanvas(
247249 layers...,
248250 )
251+
252+ t := styles.CurrentTheme()
249253 view := tea.NewView(canvas.Render())
250− view.SetBackgroundColor(t.Background())
254+ view.SetBackgroundColor(t.BgBase)
251255 view.SetCursor(cursor)
252256 return view
253257}
254258
259+// New creates and initializes a new TUI application model.
255260func New(app *app.App) tea.Model {
256261 startPage := page.ChatPage
257262 model := &appModel{
@@ -262,7 +267,7 @@ func New(app *app.App) tea.Model {
262267 keyMap: DefaultKeyMap(),
263268
264269 pages: map[page.PageID]util.Model{
265− page.ChatPage: page.NewChatPage(app),
270+ page.ChatPage: chat.NewChatPage(app),
266271 page.LogsPage: page.NewLogsPage(),
267272 },
268273
todos.mdnew file+8
@@ -0,0 +1,8 @@
1+# Chat Page
2+
3+## Landing page
4+
5+- [ ] Implement the logo landing page
6+- [ ] Add cwd improved
7+- [ ] Implement Active LSPs
8+- [ ] Implement Active MCPs