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