Detailed changes
@@ -13,7 +13,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0
github.com/charlievieth/fastwalk v1.0.11
- github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
@@ -72,10 +72,10 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367 h1:X+w3YtXyLG3oguOKXvcDT8jQP856YLQsq6SwTE+gqTk=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e h1:+3I/1v7vbN0Vf8Tjm3Q0zdLQqjOM/TjQBvoRDQtoAss=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f h1:vvNB+i59Wp3L6gYcpuhfAdNjr4/e6qM/st3ySWfmZnU=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -84,8 +84,6 @@ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4C
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
@@ -263,8 +263,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editorCmp) View() tea.View {
t := styles.CurrentTheme()
cursor := m.textarea.Cursor()
- cursor.X = cursor.X + m.x + 1
- cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+ if cursor != nil {
+ cursor.X = cursor.X + m.x + 1
+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+ }
if len(m.attachments) == 0 {
content := t.S().Base.Padding(1).Render(
m.textarea.View(),
@@ -358,11 +360,15 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
t := styles.CurrentTheme()
ta := textarea.New()
ta.SetStyles(t.S().TextArea)
- ta.SetPromptFunc(4, func(lineIndex int) string {
+ ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
if lineIndex == 0 {
return " > "
}
- return t.S().Base.Foreground(t.Blue).Render("::: ")
+ if focused {
+ return t.S().Base.Foreground(t.Blue).Render("::: ")
+ } else {
+ return t.S().Muted.Render("::: ")
+ }
})
ta.ShowLineNumbers = false
ta.CharLimit = -1
@@ -379,6 +385,23 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta
}
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+ c.textarea.Blur()
+ return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+ logging.Info("Focusing editor textarea")
+ return c.textarea.Focus()
+}
+
+// IsFocused implements Container.
+func (c *editorCmp) IsFocused() bool {
+ return c.textarea.Focused()
+}
+
func NewEditorCmp(app *app.App) util.Model {
ta := CreateTextArea(nil)
return &editorCmp{
@@ -26,10 +26,11 @@ func Title(title string, width int) string {
char := "╱"
length := lipgloss.Width(title) + 1
remainingWidth := width - length
- lineStyle := t.S().Base.Foreground(t.Primary)
titleStyle := t.S().Base.Foreground(t.Primary)
if remainingWidth > 0 {
- title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
+ lines := strings.Repeat(char, remainingWidth)
+ lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
+ title = titleStyle.Render(title) + " " + lines
}
return title
}
@@ -0,0 +1,49 @@
+package status
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ Tab,
+ Commands,
+ Help key.Binding
+}
+
+func DefaultKeyMap(tabHelp string) KeyMap {
+ return KeyMap{
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", tabHelp),
+ ),
+ Commands: key.NewBinding(
+ key.WithKeys("ctrl+p"),
+ key.WithHelp("ctrl+p", "commands"),
+ ),
+ Help: key.NewBinding(
+ key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
+ key.WithHelp("ctrl+?", "more"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := layout.KeyMapToSlice(k)
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ k.Tab,
+ k.Commands,
+ k.Help,
+ }
+}
@@ -0,0 +1,113 @@
+package status
+
+import (
+ "time"
+
+ "github.com/charmbracelet/bubbles/v2/help"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/pubsub"
+ "github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type StatusCmp interface {
+ util.Model
+}
+
+type statusCmp struct {
+ info util.InfoMsg
+ width int
+ messageTTL time.Duration
+ session session.Session
+ help help.Model
+}
+
+// clearMessageCmd is a command that clears status messages after a timeout
+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
+ return tea.Tick(ttl, func(time.Time) tea.Msg {
+ return util.ClearStatusMsg{}
+ })
+}
+
+func (m statusCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ return m, nil
+
+ // Handle status info
+ case util.InfoMsg:
+ m.info = msg
+ ttl := msg.TTL
+ if ttl == 0 {
+ ttl = m.messageTTL
+ }
+ return m, m.clearMessageCmd(ttl)
+ case util.ClearStatusMsg:
+ m.info = util.InfoMsg{}
+
+ // Handle persistent logs
+ case pubsub.Event[logging.LogMessage]:
+ if msg.Payload.Persist {
+ switch msg.Payload.Level {
+ case "error":
+ m.info = util.InfoMsg{
+ Type: util.InfoTypeError,
+ Msg: msg.Payload.Message,
+ TTL: msg.Payload.PersistTime,
+ }
+ case "info":
+ m.info = util.InfoMsg{
+ Type: util.InfoTypeInfo,
+ Msg: msg.Payload.Message,
+ TTL: msg.Payload.PersistTime,
+ }
+ case "warn":
+ m.info = util.InfoMsg{
+ Type: util.InfoTypeWarn,
+ Msg: msg.Payload.Message,
+ TTL: msg.Payload.PersistTime,
+ }
+ default:
+ m.info = util.InfoMsg{
+ Type: util.InfoTypeInfo,
+ Msg: msg.Payload.Message,
+ TTL: msg.Payload.PersistTime,
+ }
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m statusCmp) View() tea.View {
+ t := styles.CurrentTheme()
+ status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+ if m.info.Msg != "" {
+ switch m.info.Type {
+ case util.InfoTypeError:
+ status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+ case util.InfoTypeWarn:
+ status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+ default:
+ status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+ }
+ }
+ return tea.NewView(status)
+}
+
+func NewStatusCmp() StatusCmp {
+ t := styles.CurrentTheme()
+ help := help.New()
+ help.Styles = t.S().Help
+ return &statusCmp{
+ messageTTL: 10 * time.Second,
+ help: help,
+ }
+}
@@ -160,9 +160,9 @@ func (c *commandDialogCmp) commandTypeRadio() string {
iconSelected := "◉"
iconUnselected := "○"
if c.commandType == SystemCommands {
- return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
}
- return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
}
func (c *commandDialogCmp) listWidth() int {
@@ -10,8 +10,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/slice"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/rivo/uniseg"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
)
// letterform represents a letterform. It can be stretched horizontally by
@@ -46,7 +45,7 @@ func Render(version string, compact bool, o Opts) string {
crushWidth := lipgloss.Width(crush)
b := new(strings.Builder)
for r := range strings.SplitSeq(crush, "\n") {
- fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
}
crush = b.String()
@@ -312,76 +311,3 @@ func stretchLetterformPart(s string, p letterformProps) string {
}
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
}
-
-// applyForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func applyForegroundGrad(input string, color1, color2 color.Color) string {
- if input == "" {
- return ""
- }
-
- var o strings.Builder
- if len(input) == 1 {
- return lipgloss.NewStyle().Foreground(color1).Render(input)
- }
-
- var clusters []string
- gr := uniseg.NewGraphemes(input)
- for gr.Next() {
- clusters = append(clusters, string(gr.Runes()))
- }
-
- ramp := blendColors(len(clusters), color1, color2)
- for i, c := range ramp {
- fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
- }
-
- return o.String()
-}
-
-// blendColors returns a slice of colors blended between the given keys.
-// Blending is done in Hcl to stay in gamut.
-func blendColors(size int, stops ...color.Color) []color.Color {
- if len(stops) < 2 {
- return nil
- }
-
- stopsPrime := make([]colorful.Color, len(stops))
- for i, k := range stops {
- stopsPrime[i], _ = colorful.MakeColor(k)
- }
-
- numSegments := len(stopsPrime) - 1
- blended := make([]color.Color, 0, size)
-
- // Calculate how many colors each segment should have.
- segmentSizes := make([]int, numSegments)
- baseSize := size / numSegments
- remainder := size % numSegments
-
- // Distribute the remainder across segments.
- for i := range numSegments {
- segmentSizes[i] = baseSize
- if i < remainder {
- segmentSizes[i]++
- }
- }
-
- // Generate colors for each segment.
- for i := range numSegments {
- c1 := stopsPrime[i]
- c2 := stopsPrime[i+1]
- segmentSize := segmentSizes[i]
-
- for j := range segmentSize {
- var t float64
- if segmentSize > 1 {
- t = float64(j) / float64(segmentSize-1)
- }
- c := c1.BlendHcl(c2, t)
- blended = append(blended, c)
- }
- }
-
- return blended
-}
@@ -6,13 +6,11 @@ import (
)
type KeyMap struct {
- Logs key.Binding
- Quit key.Binding
- Help key.Binding
- Commands key.Binding
- FilePicker key.Binding
- Models key.Binding
- SwitchTheme key.Binding
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ FilePicker key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -21,7 +19,6 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+l"),
key.WithHelp("ctrl+l", "logs"),
),
-
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
@@ -31,24 +28,14 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+_"),
key.WithHelp("ctrl+?", "toggle help"),
),
-
Commands: key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("ctrl+k", "commands"),
+ key.WithKeys("ctrl+p"),
+ key.WithHelp("ctrl+p", "commands"),
),
FilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "select files to upload"),
),
- Models: key.NewBinding(
- key.WithKeys("ctrl+o"),
- key.WithHelp("ctrl+o", "model selection"),
- ),
-
- SwitchTheme: key.NewBinding(
- key.WithKeys("ctrl+t"),
- key.WithHelp("ctrl+t", "switch theme"),
- ),
}
}
@@ -13,10 +13,12 @@ type Container interface {
Sizeable
Bindings
Positionable
+ Focusable
}
type container struct {
- width int
- height int
+ width int
+ height int
+ isFocused bool
x, y int
@@ -35,14 +37,39 @@ type container struct {
borderStyle lipgloss.Border
}
+type ContainerOption func(*container)
+
+func NewContainer(content util.Model, options ...ContainerOption) Container {
+ c := &container{
+ content: content,
+ borderStyle: lipgloss.NormalBorder(),
+ }
+
+ for _, option := range options {
+ option(c)
+ }
+
+ return c
+}
+
func (c *container) Init() tea.Cmd {
return c.content.Init()
}
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- u, cmd := c.content.Update(msg)
- c.content = u.(util.Model)
- return c, cmd
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if c.IsFocused() {
+ u, cmd := c.content.Update(msg)
+ c.content = u.(util.Model)
+ return c, cmd
+ }
+ return c, nil
+ default:
+ u, cmd := c.content.Update(msg)
+ c.content = u.(util.Model)
+ return c, cmd
+ }
}
func (c *container) View() tea.View {
@@ -80,7 +107,8 @@ func (c *container) View() tea.View {
contentView := c.content.View()
view := tea.NewView(style.Render(contentView.String()))
- view.SetCursor(contentView.Cursor())
+ cursor := contentView.Cursor()
+ view.SetCursor(cursor)
return view
}
@@ -136,19 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
return []key.Binding{}
}
-type ContainerOption func(*container)
-
-func NewContainer(content util.Model, options ...ContainerOption) Container {
- c := &container{
- content: content,
- borderStyle: lipgloss.NormalBorder(),
+// Blur implements Container.
+func (c *container) Blur() tea.Cmd {
+ c.isFocused = false
+ if focusable, ok := c.content.(Focusable); ok {
+ return focusable.Blur()
}
+ return nil
+}
- for _, option := range options {
- option(c)
+// Focus implements Container.
+func (c *container) Focus() tea.Cmd {
+ c.isFocused = true
+ if focusable, ok := c.content.(Focusable); ok {
+ return focusable.Focus()
}
+ return nil
+}
- return c
+// IsFocused implements Container.
+func (c *container) IsFocused() bool {
+ isFocused := c.isFocused
+ if focusable, ok := c.content.(Focusable); ok {
+ isFocused = isFocused || focusable.IsFocused()
+ }
+ return isFocused
}
// Padding options
@@ -8,6 +8,14 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
+type LayoutPanel string
+
+const (
+ LeftPanel LayoutPanel = "left"
+ RightPanel LayoutPanel = "right"
+ BottomPanel LayoutPanel = "bottom"
+)
+
type SplitPaneLayout interface {
util.Model
Sizeable
@@ -19,6 +27,8 @@ type SplitPaneLayout interface {
ClearLeftPanel() tea.Cmd
ClearRightPanel() tea.Cmd
ClearBottomPanel() tea.Cmd
+
+ FocusPanel(panel LayoutPanel) tea.Cmd
}
type splitPaneLayout struct {
@@ -279,6 +289,26 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
return keys
}
+func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
+ panels := map[LayoutPanel]Container{
+ LeftPanel: s.leftPanel,
+ RightPanel: s.rightPanel,
+ BottomPanel: s.bottomPanel,
+ }
+ var cmds []tea.Cmd
+ for p, container := range panels {
+ if container == nil {
+ continue
+ }
+ if p == panel {
+ cmds = append(cmds, container.Focus())
+ } else {
+ cmds = append(cmds, container.Blur())
+ }
+ }
+ return tea.Batch(cmds...)
+}
+
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
layout := &splitPaneLayout{
ratio: 0.8,
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -19,32 +20,28 @@ import (
var ChatPage page.PageID = "chat"
+type ChatFocusedMsg struct {
+ Focused bool // True if the chat input is focused, false otherwise
+}
+
type chatPage struct {
app *app.App
layout layout.SplitPaneLayout
session session.Session
-}
-type ChatKeyMap struct {
- NewSession key.Binding
- Cancel key.Binding
-}
+ keyMap KeyMap
-var keyMap = ChatKeyMap{
- NewSession: key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "new session"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
+ chatFocused bool
}
func (p *chatPage) Init() tea.Cmd {
- return p.layout.Init()
+ cmd := p.layout.Init()
+ return tea.Batch(
+ cmd,
+ p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
+ )
}
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -79,13 +76,28 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.session = msg
case tea.KeyPressMsg:
switch {
- case key.Matches(msg, keyMap.NewSession):
+ case key.Matches(msg, p.keyMap.NewSession):
p.session = session.Session{}
return p, tea.Batch(
p.clearMessages(),
util.CmdHandler(chat.SessionClearedMsg{}),
)
- case key.Matches(msg, keyMap.Cancel):
+
+ case key.Matches(msg, p.keyMap.Tab):
+ logging.Info("Tab key pressed, toggling chat focus")
+ if p.session.ID == "" {
+ return p, nil
+ }
+ p.chatFocused = !p.chatFocused
+ if p.chatFocused {
+ cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
+ } else {
+ cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
+ }
+ return p, tea.Batch(cmds...)
+ case key.Matches(msg, p.keyMap.Cancel):
if p.session.ID != "" {
// Cancel the current session's generation process
// This allows users to interrupt long-running operations
@@ -148,11 +160,6 @@ func (p *chatPage) View() tea.View {
return p.layout.View()
}
-func (p *chatPage) BindingKeys() []key.Binding {
- bindings := layout.KeyMapToSlice(keyMap)
- return bindings
-}
-
func NewChatPage(app *app.App) util.Model {
sidebarContainer := layout.NewContainer(
sidebar.NewSidebarCmp(),
@@ -169,5 +176,6 @@ func NewChatPage(app *app.App) util.Model {
layout.WithFixedBottomHeight(5),
layout.WithFixedRightWidth(31),
),
+ keyMap: DefaultKeyMap(),
}
}
@@ -1 +1,47 @@
package chat
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ NewSession key.Binding
+ Cancel key.Binding
+ Tab key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ NewSession: key.NewBinding(
+ key.WithKeys("ctrl+n"),
+ key.WithHelp("ctrl+n", "new session"),
+ ),
+ Cancel: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "change focus"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := layout.KeyMapToSlice(k)
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ k.Tab,
+ }
+}
@@ -24,10 +24,11 @@ func NewCrushTheme() *Theme {
BgOverlay: charmtone.Iron,
// Foregrounds
- FgBase: charmtone.Ash,
- FgMuted: charmtone.Squid,
- FgSubtle: charmtone.Oyster,
- FgSelected: charmtone.Salt,
+ FgBase: charmtone.Ash,
+ FgMuted: charmtone.Squid,
+ FgHalfMuted: charmtone.Smoke,
+ FgSubtle: charmtone.Oyster,
+ FgSelected: charmtone.Salt,
// Borders
Border: charmtone.Charcoal,
@@ -3,6 +3,7 @@ package styles
import (
"fmt"
"image/color"
+ "strings"
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/textarea"
@@ -10,6 +11,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/glamour/v2/ansi"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/rivo/uniseg"
)
const (
@@ -35,10 +38,11 @@ type Theme struct {
BgSubtle color.Color
BgOverlay color.Color
- FgBase color.Color
- FgMuted color.Color
- FgSubtle color.Color
- FgSelected color.Color
+ FgBase color.Color
+ FgMuted color.Color
+ FgHalfMuted color.Color
+ FgSubtle color.Color
+ FgSelected color.Color
Border color.Color
BorderFocus color.Color
@@ -491,3 +495,76 @@ func Lighten(c color.Color, percent float64) color.Color {
A: uint8(a >> 8),
}
}
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
+ if input == "" {
+ return ""
+ }
+
+ var o strings.Builder
+ if len(input) == 1 {
+ return lipgloss.NewStyle().Foreground(color1).Render(input)
+ }
+
+ var clusters []string
+ gr := uniseg.NewGraphemes(input)
+ for gr.Next() {
+ clusters = append(clusters, string(gr.Runes()))
+ }
+
+ ramp := blendColors(len(clusters), color1, color2)
+ for i, c := range ramp {
+ fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
+ }
+
+ return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+ if len(stops) < 2 {
+ return nil
+ }
+
+ stopsPrime := make([]colorful.Color, len(stops))
+ for i, k := range stops {
+ stopsPrime[i], _ = colorful.MakeColor(k)
+ }
+
+ numSegments := len(stopsPrime) - 1
+ blended := make([]color.Color, 0, size)
+
+ // Calculate how many colors each segment should have.
+ segmentSizes := make([]int, numSegments)
+ baseSize := size / numSegments
+ remainder := size % numSegments
+
+ // Distribute the remainder across segments.
+ for i := range numSegments {
+ segmentSizes[i] = baseSize
+ if i < remainder {
+ segmentSizes[i]++
+ }
+ }
+
+ // Generate colors for each segment.
+ for i := range numSegments {
+ c1 := stopsPrime[i]
+ c2 := stopsPrime[i+1]
+ segmentSize := segmentSizes[i]
+
+ for j := range segmentSize {
+ var t float64
+ if segmentSize > 1 {
+ t = float64(j) / float64(segmentSize-1)
+ }
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
+}
@@ -12,6 +12,7 @@ import (
cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
"github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/status"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
@@ -34,7 +35,7 @@ type appModel struct {
pages map[page.PageID]util.Model
loadedPages map[page.PageID]bool
- status core.StatusCmp
+ status status.StatusCmp
app *app.App
@@ -288,7 +289,7 @@ func New(app *app.App) tea.Model {
model := &appModel{
currentPage: startPage,
app: app,
- status: core.NewStatusCmp(app.LSPClients),
+ status: status.NewStatusCmp(),
loadedPages: make(map[page.PageID]bool),
keyMap: DefaultKeyMap(),
@@ -13,3 +13,8 @@
- [x] Sessions dialog
- [ ] Models
- [~] Move sessions and model dialog to the commands
+
+## Investigate
+
+- [ ] Events when tool error
+- [ ] Fancy Spinner