on main help sign in
wip focus and changes
commitd9f7a41818796ec7a3a2b2f8d43cfbfba520cea9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 4 Jun 2025, 11:51 (1 year ago) parent67529e5
17 files changed+463−158
go.mod+1−1 go.sum+4−6 internal/tui/components/chat/editor/editor.go+27−4 internal/tui/components/core/helpers.go+3−2 internal/tui/components/core/status/keys.go+49 internal/tui/components/core/status/status.go+113 internal/tui/components/dialogs/commands/commands.go+2−2 internal/tui/components/logo/logo.go+2−76 internal/tui/keys.go+7−20 internal/tui/layout/container.go+55−15 internal/tui/layout/split.go+30 internal/tui/page/chat/chat.go+30−22 internal/tui/page/chat/keys.go+46 internal/tui/styles/crush.go+5−4 internal/tui/styles/theme.go+81−4 internal/tui/tui.go+3−2 todos.md+5
go.mod+1−1
@@ -13,7 +13,7 @@ require (
1313 github.com/bmatcuk/doublestar/v4 v4.8.1
1414 github.com/catppuccin/go v0.3.0
1515 github.com/charlievieth/fastwalk v1.0.11
16− github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
16+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f
1717 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
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
go.sum+4−6
@@ -72,10 +72,10 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
7272github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
7373github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
7474github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
75−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
76−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
77−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367 h1:X+w3YtXyLG3oguOKXvcDT8jQP856YLQsq6SwTE+gqTk=
78−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
75+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e h1:+3I/1v7vbN0Vf8Tjm3Q0zdLQqjOM/TjQBvoRDQtoAss=
76+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
77+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f h1:vvNB+i59Wp3L6gYcpuhfAdNjr4/e6qM/st3ySWfmZnU=
78+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7979github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
8080github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
8181github.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
8484github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
8585github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
8686github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
87−github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
88−github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
8987github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
9088github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
9189github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
internal/tui/components/chat/editor/editor.go+27−4
@@ -263,8 +263,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
263263func (m *editorCmp) View() tea.View {
264264 t := styles.CurrentTheme()
265265 cursor := m.textarea.Cursor()
266− cursor.X = cursor.X + m.x + 1
267− cursor.Y = cursor.Y + m.y + 1 // adjust for padding
266+ if cursor != nil {
267+ cursor.X = cursor.X + m.x + 1
268+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
269+ }
268270 if len(m.attachments) == 0 {
269271 content := t.S().Base.Padding(1).Render(
270272 m.textarea.View(),
@@ -358,11 +360,15 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
358360 t := styles.CurrentTheme()
359361 ta := textarea.New()
360362 ta.SetStyles(t.S().TextArea)
361− ta.SetPromptFunc(4, func(lineIndex int) string {
363+ ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
362364 if lineIndex == 0 {
363365 return " > "
364366 }
365− return t.S().Base.Foreground(t.Blue).Render("::: ")
367+ if focused {
368+ return t.S().Base.Foreground(t.Blue).Render("::: ")
369+ } else {
370+ return t.S().Muted.Render("::: ")
371+ }
366372 })
367373 ta.ShowLineNumbers = false
368374 ta.CharLimit = -1
@@ -379,6 +385,23 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
379385 return ta
380386}
381387
388+// Blur implements Container.
389+func (c *editorCmp) Blur() tea.Cmd {
390+ c.textarea.Blur()
391+ return nil
392+}
393+
394+// Focus implements Container.
395+func (c *editorCmp) Focus() tea.Cmd {
396+ logging.Info("Focusing editor textarea")
397+ return c.textarea.Focus()
398+}
399+
400+// IsFocused implements Container.
401+func (c *editorCmp) IsFocused() bool {
402+ return c.textarea.Focused()
403+}
404+
382405func NewEditorCmp(app *app.App) util.Model {
383406 ta := CreateTextArea(nil)
384407 return &editorCmp{
internal/tui/components/core/helpers.go+3−2
@@ -26,10 +26,11 @@ func Title(title string, width int) string {
2626 char := "╱"
2727 length := lipgloss.Width(title) + 1
2828 remainingWidth := width - length
29− lineStyle := t.S().Base.Foreground(t.Primary)
3029 titleStyle := t.S().Base.Foreground(t.Primary)
3130 if remainingWidth > 0 {
32− title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
31+ lines := strings.Repeat(char, remainingWidth)
32+ lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
33+ title = titleStyle.Render(title) + " " + lines
3334 }
3435 return title
3536}
internal/tui/components/core/status/keys.gonew file+49
@@ -0,0 +1,49 @@
1+package status
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Tab,
10+ Commands,
11+ Help key.Binding
12+}
13+
14+func DefaultKeyMap(tabHelp string) KeyMap {
15+ return KeyMap{
16+ Tab: key.NewBinding(
17+ key.WithKeys("tab"),
18+ key.WithHelp("tab", tabHelp),
19+ ),
20+ Commands: key.NewBinding(
21+ key.WithKeys("ctrl+p"),
22+ key.WithHelp("ctrl+p", "commands"),
23+ ),
24+ Help: key.NewBinding(
25+ key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
26+ key.WithHelp("ctrl+?", "more"),
27+ ),
28+ }
29+}
30+
31+// FullHelp implements help.KeyMap.
32+func (k KeyMap) FullHelp() [][]key.Binding {
33+ m := [][]key.Binding{}
34+ slice := layout.KeyMapToSlice(k)
35+ for i := 0; i < len(slice); i += 4 {
36+ end := min(i+4, len(slice))
37+ m = append(m, slice[i:end])
38+ }
39+ return m
40+}
41+
42+// ShortHelp implements help.KeyMap.
43+func (k KeyMap) ShortHelp() []key.Binding {
44+ return []key.Binding{
45+ k.Tab,
46+ k.Commands,
47+ k.Help,
48+ }
49+}
internal/tui/components/core/status/status.gonew file+113
@@ -0,0 +1,113 @@
1+package status
2+
3+import (
4+ "time"
5+
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/opencode-ai/opencode/internal/logging"
9+ "github.com/opencode-ai/opencode/internal/pubsub"
10+ "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/opencode-ai/opencode/internal/tui/styles"
12+ "github.com/opencode-ai/opencode/internal/tui/util"
13+)
14+
15+type StatusCmp interface {
16+ util.Model
17+}
18+
19+type statusCmp struct {
20+ info util.InfoMsg
21+ width int
22+ messageTTL time.Duration
23+ session session.Session
24+ help help.Model
25+}
26+
27+// clearMessageCmd is a command that clears status messages after a timeout
28+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
29+ return tea.Tick(ttl, func(time.Time) tea.Msg {
30+ return util.ClearStatusMsg{}
31+ })
32+}
33+
34+func (m statusCmp) Init() tea.Cmd {
35+ return nil
36+}
37+
38+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39+ switch msg := msg.(type) {
40+ case tea.WindowSizeMsg:
41+ m.width = msg.Width
42+ return m, nil
43+
44+ // Handle status info
45+ case util.InfoMsg:
46+ m.info = msg
47+ ttl := msg.TTL
48+ if ttl == 0 {
49+ ttl = m.messageTTL
50+ }
51+ return m, m.clearMessageCmd(ttl)
52+ case util.ClearStatusMsg:
53+ m.info = util.InfoMsg{}
54+
55+ // Handle persistent logs
56+ case pubsub.Event[logging.LogMessage]:
57+ if msg.Payload.Persist {
58+ switch msg.Payload.Level {
59+ case "error":
60+ m.info = util.InfoMsg{
61+ Type: util.InfoTypeError,
62+ Msg: msg.Payload.Message,
63+ TTL: msg.Payload.PersistTime,
64+ }
65+ case "info":
66+ m.info = util.InfoMsg{
67+ Type: util.InfoTypeInfo,
68+ Msg: msg.Payload.Message,
69+ TTL: msg.Payload.PersistTime,
70+ }
71+ case "warn":
72+ m.info = util.InfoMsg{
73+ Type: util.InfoTypeWarn,
74+ Msg: msg.Payload.Message,
75+ TTL: msg.Payload.PersistTime,
76+ }
77+ default:
78+ m.info = util.InfoMsg{
79+ Type: util.InfoTypeInfo,
80+ Msg: msg.Payload.Message,
81+ TTL: msg.Payload.PersistTime,
82+ }
83+ }
84+ }
85+ }
86+ return m, nil
87+}
88+
89+func (m statusCmp) View() tea.View {
90+ t := styles.CurrentTheme()
91+ status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
92+ if m.info.Msg != "" {
93+ switch m.info.Type {
94+ case util.InfoTypeError:
95+ status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
96+ case util.InfoTypeWarn:
97+ status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
98+ default:
99+ status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
100+ }
101+ }
102+ return tea.NewView(status)
103+}
104+
105+func NewStatusCmp() StatusCmp {
106+ t := styles.CurrentTheme()
107+ help := help.New()
108+ help.Styles = t.S().Help
109+ return &statusCmp{
110+ messageTTL: 10 * time.Second,
111+ help: help,
112+ }
113+}
internal/tui/components/dialogs/commands/commands.go+2−2
@@ -160,9 +160,9 @@ func (c *commandDialogCmp) commandTypeRadio() string {
160160 iconSelected := "◉"
161161 iconUnselected := "○"
162162 if c.commandType == SystemCommands {
163− return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
163+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
164164 }
165− return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
165+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
166166}
167167
168168func (c *commandDialogCmp) listWidth() int {
internal/tui/components/logo/logo.go+2−76
@@ -10,8 +10,7 @@ import (
1010 "github.com/charmbracelet/lipgloss/v2"
1111 "github.com/charmbracelet/x/ansi"
1212 "github.com/charmbracelet/x/exp/slice"
13− "github.com/lucasb-eyer/go-colorful"
14− "github.com/rivo/uniseg"
13+ "github.com/opencode-ai/opencode/internal/tui/styles"
1514)
1615
1716// letterform represents a letterform. It can be stretched horizontally by
@@ -46,7 +45,7 @@ func Render(version string, compact bool, o Opts) string {
4645 crushWidth := lipgloss.Width(crush)
4746 b := new(strings.Builder)
4847 for r := range strings.SplitSeq(crush, "\n") {
49− fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
48+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
5049 }
5150 crush = b.String()
5251
@@ -312,76 +311,3 @@ func stretchLetterformPart(s string, p letterformProps) string {
312311 }
313312 return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
314313}
315−
316−// applyForegroundGrad renders a given string with a horizontal gradient
317−// foreground.
318−func applyForegroundGrad(input string, color1, color2 color.Color) string {
319− if input == "" {
320− return ""
321− }
322−
323− var o strings.Builder
324− if len(input) == 1 {
325− return lipgloss.NewStyle().Foreground(color1).Render(input)
326− }
327−
328− var clusters []string
329− gr := uniseg.NewGraphemes(input)
330− for gr.Next() {
331− clusters = append(clusters, string(gr.Runes()))
332− }
333−
334− ramp := blendColors(len(clusters), color1, color2)
335− for i, c := range ramp {
336− fmt.Fprint(&o, lipgloss.NewStyle().Foreground(c).Render(clusters[i]))
337− }
338−
339− return o.String()
340−}
341−
342−// blendColors returns a slice of colors blended between the given keys.
343−// Blending is done in Hcl to stay in gamut.
344−func blendColors(size int, stops ...color.Color) []color.Color {
345− if len(stops) < 2 {
346− return nil
347− }
348−
349− stopsPrime := make([]colorful.Color, len(stops))
350− for i, k := range stops {
351− stopsPrime[i], _ = colorful.MakeColor(k)
352− }
353−
354− numSegments := len(stopsPrime) - 1
355− blended := make([]color.Color, 0, size)
356−
357− // Calculate how many colors each segment should have.
358− segmentSizes := make([]int, numSegments)
359− baseSize := size / numSegments
360− remainder := size % numSegments
361−
362− // Distribute the remainder across segments.
363− for i := range numSegments {
364− segmentSizes[i] = baseSize
365− if i < remainder {
366− segmentSizes[i]++
367− }
368− }
369−
370− // Generate colors for each segment.
371− for i := range numSegments {
372− c1 := stopsPrime[i]
373− c2 := stopsPrime[i+1]
374− segmentSize := segmentSizes[i]
375−
376− for j := range segmentSize {
377− var t float64
378− if segmentSize > 1 {
379− t = float64(j) / float64(segmentSize-1)
380− }
381− c := c1.BlendHcl(c2, t)
382− blended = append(blended, c)
383− }
384− }
385−
386− return blended
387−}
internal/tui/keys.go+7−20
@@ -6,13 +6,11 @@ import (
66)
77
88type KeyMap struct {
9− Logs key.Binding
10− Quit key.Binding
11− Help key.Binding
12− Commands key.Binding
13− FilePicker key.Binding
14− Models key.Binding
15− SwitchTheme key.Binding
9+ Logs key.Binding
10+ Quit key.Binding
11+ Help key.Binding
12+ Commands key.Binding
13+ FilePicker key.Binding
1614}
1715
1816func DefaultKeyMap() KeyMap {
@@ -21,7 +19,6 @@ func DefaultKeyMap() KeyMap {
2119 key.WithKeys("ctrl+l"),
2220 key.WithHelp("ctrl+l", "logs"),
2321 ),
24−
2522 Quit: key.NewBinding(
2623 key.WithKeys("ctrl+c"),
2724 key.WithHelp("ctrl+c", "quit"),
@@ -31,24 +28,14 @@ func DefaultKeyMap() KeyMap {
3128 key.WithKeys("ctrl+_"),
3229 key.WithHelp("ctrl+?", "toggle help"),
3330 ),
34−
3531 Commands: key.NewBinding(
36− key.WithKeys("ctrl+k"),
37− key.WithHelp("ctrl+k", "commands"),
32+ key.WithKeys("ctrl+p"),
33+ key.WithHelp("ctrl+p", "commands"),
3834 ),
3935 FilePicker: key.NewBinding(
4036 key.WithKeys("ctrl+f"),
4137 key.WithHelp("ctrl+f", "select files to upload"),
4238 ),
43− Models: key.NewBinding(
44− key.WithKeys("ctrl+o"),
45− key.WithHelp("ctrl+o", "model selection"),
46− ),
47−
48− SwitchTheme: key.NewBinding(
49− key.WithKeys("ctrl+t"),
50− key.WithHelp("ctrl+t", "switch theme"),
51− ),
5239 }
5340}
5441
internal/tui/layout/container.go+55−15
@@ -13,10 +13,12 @@ type Container interface {
1313 Sizeable
1414 Bindings
1515 Positionable
16+ Focusable
1617}
1718type container struct {
18− width int
19− height int
19+ width int
20+ height int
21+ isFocused bool
2022
2123 x, y int
2224
@@ -35,14 +37,39 @@ type container struct {
3537 borderStyle lipgloss.Border
3638}
3739
40+type ContainerOption func(*container)
41+
42+func NewContainer(content util.Model, options ...ContainerOption) Container {
43+ c := &container{
44+ content: content,
45+ borderStyle: lipgloss.NormalBorder(),
46+ }
47+
48+ for _, option := range options {
49+ option(c)
50+ }
51+
52+ return c
53+}
54+
3855func (c *container) Init() tea.Cmd {
3956 return c.content.Init()
4057}
4158
4259func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43− u, cmd := c.content.Update(msg)
44− c.content = u.(util.Model)
45− return c, cmd
60+ switch msg := msg.(type) {
61+ case tea.KeyPressMsg:
62+ if c.IsFocused() {
63+ u, cmd := c.content.Update(msg)
64+ c.content = u.(util.Model)
65+ return c, cmd
66+ }
67+ return c, nil
68+ default:
69+ u, cmd := c.content.Update(msg)
70+ c.content = u.(util.Model)
71+ return c, cmd
72+ }
4673}
4774
4875func (c *container) View() tea.View {
@@ -80,7 +107,8 @@ func (c *container) View() tea.View {
80107
81108 contentView := c.content.View()
82109 view := tea.NewView(style.Render(contentView.String()))
83− view.SetCursor(contentView.Cursor())
110+ cursor := contentView.Cursor()
111+ view.SetCursor(cursor)
84112 return view
85113}
86114
@@ -136,19 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
136164 return []key.Binding{}
137165}
138166
139−type ContainerOption func(*container)
140−
141−func NewContainer(content util.Model, options ...ContainerOption) Container {
142− c := &container{
143− content: content,
144− borderStyle: lipgloss.NormalBorder(),
167+// Blur implements Container.
168+func (c *container) Blur() tea.Cmd {
169+ c.isFocused = false
170+ if focusable, ok := c.content.(Focusable); ok {
171+ return focusable.Blur()
145172 }
173+ return nil
174+}
146175
147− for _, option := range options {
148− option(c)
176+// Focus implements Container.
177+func (c *container) Focus() tea.Cmd {
178+ c.isFocused = true
179+ if focusable, ok := c.content.(Focusable); ok {
180+ return focusable.Focus()
149181 }
182+ return nil
183+}
150184
151− return c
185+// IsFocused implements Container.
186+func (c *container) IsFocused() bool {
187+ isFocused := c.isFocused
188+ if focusable, ok := c.content.(Focusable); ok {
189+ isFocused = isFocused || focusable.IsFocused()
190+ }
191+ return isFocused
152192}
153193
154194// Padding options
internal/tui/layout/split.go+30
@@ -8,6 +8,14 @@ import (
88 "github.com/opencode-ai/opencode/internal/tui/util"
99)
1010
11+type LayoutPanel string
12+
13+const (
14+ LeftPanel LayoutPanel = "left"
15+ RightPanel LayoutPanel = "right"
16+ BottomPanel LayoutPanel = "bottom"
17+)
18+
1119type SplitPaneLayout interface {
1220 util.Model
1321 Sizeable
@@ -19,6 +27,8 @@ type SplitPaneLayout interface {
1927 ClearLeftPanel() tea.Cmd
2028 ClearRightPanel() tea.Cmd
2129 ClearBottomPanel() tea.Cmd
30+
31+ FocusPanel(panel LayoutPanel) tea.Cmd
2232}
2333
2434type splitPaneLayout struct {
@@ -279,6 +289,26 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
279289 return keys
280290}
281291
292+func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
293+ panels := map[LayoutPanel]Container{
294+ LeftPanel: s.leftPanel,
295+ RightPanel: s.rightPanel,
296+ BottomPanel: s.bottomPanel,
297+ }
298+ var cmds []tea.Cmd
299+ for p, container := range panels {
300+ if container == nil {
301+ continue
302+ }
303+ if p == panel {
304+ cmds = append(cmds, container.Focus())
305+ } else {
306+ cmds = append(cmds, container.Blur())
307+ }
308+ }
309+ return tea.Batch(cmds...)
310+}
311+
282312func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
283313 layout := &splitPaneLayout{
284314 ratio: 0.8,
internal/tui/page/chat/chat.go+30−22
@@ -6,6 +6,7 @@ import (
66 "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/opencode-ai/opencode/internal/app"
9+ "github.com/opencode-ai/opencode/internal/logging"
910 "github.com/opencode-ai/opencode/internal/message"
1011 "github.com/opencode-ai/opencode/internal/session"
1112 "github.com/opencode-ai/opencode/internal/tui/components/chat"
@@ -19,32 +20,28 @@ import (
1920
2021var ChatPage page.PageID = "chat"
2122
23+type ChatFocusedMsg struct {
24+ Focused bool // True if the chat input is focused, false otherwise
25+}
26+
2227type chatPage struct {
2328 app *app.App
2429
2530 layout layout.SplitPaneLayout
2631
2732 session session.Session
28−}
2933
30−type ChatKeyMap struct {
31− NewSession key.Binding
32− Cancel key.Binding
33−}
34+ keyMap KeyMap
3435
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− ),
36+ chatFocused bool
4437}
4538
4639func (p *chatPage) Init() tea.Cmd {
47− return p.layout.Init()
40+ cmd := p.layout.Init()
41+ return tea.Batch(
42+ cmd,
43+ p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
44+ )
4845}
4946
5047func (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) {
7976 p.session = msg
8077 case tea.KeyPressMsg:
8178 switch {
82− case key.Matches(msg, keyMap.NewSession):
79+ case key.Matches(msg, p.keyMap.NewSession):
8380 p.session = session.Session{}
8481 return p, tea.Batch(
8582 p.clearMessages(),
8683 util.CmdHandler(chat.SessionClearedMsg{}),
8784 )
88− case key.Matches(msg, keyMap.Cancel):
85+
86+ case key.Matches(msg, p.keyMap.Tab):
87+ logging.Info("Tab key pressed, toggling chat focus")
88+ if p.session.ID == "" {
89+ return p, nil
90+ }
91+ p.chatFocused = !p.chatFocused
92+ if p.chatFocused {
93+ cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
94+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
95+ } else {
96+ cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
97+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
98+ }
99+ return p, tea.Batch(cmds...)
100+ case key.Matches(msg, p.keyMap.Cancel):
89101 if p.session.ID != "" {
90102 // Cancel the current session's generation process
91103 // This allows users to interrupt long-running operations
@@ -148,11 +160,6 @@ func (p *chatPage) View() tea.View {
148160 return p.layout.View()
149161}
150162
151−func (p *chatPage) BindingKeys() []key.Binding {
152− bindings := layout.KeyMapToSlice(keyMap)
153− return bindings
154−}
155−
156163func NewChatPage(app *app.App) util.Model {
157164 sidebarContainer := layout.NewContainer(
158165 sidebar.NewSidebarCmp(),
@@ -169,5 +176,6 @@ func NewChatPage(app *app.App) util.Model {
169176 layout.WithFixedBottomHeight(5),
170177 layout.WithFixedRightWidth(31),
171178 ),
179+ keyMap: DefaultKeyMap(),
172180 }
173181}
internal/tui/page/chat/keys.go+46
@@ -1 +1,47 @@
11package chat
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ NewSession key.Binding
10+ Cancel key.Binding
11+ Tab key.Binding
12+}
13+
14+func DefaultKeyMap() KeyMap {
15+ return KeyMap{
16+ NewSession: key.NewBinding(
17+ key.WithKeys("ctrl+n"),
18+ key.WithHelp("ctrl+n", "new session"),
19+ ),
20+ Cancel: key.NewBinding(
21+ key.WithKeys("esc"),
22+ key.WithHelp("esc", "cancel"),
23+ ),
24+ Tab: key.NewBinding(
25+ key.WithKeys("tab"),
26+ key.WithHelp("tab", "change focus"),
27+ ),
28+ }
29+}
30+
31+// FullHelp implements help.KeyMap.
32+func (k KeyMap) FullHelp() [][]key.Binding {
33+ m := [][]key.Binding{}
34+ slice := layout.KeyMapToSlice(k)
35+ for i := 0; i < len(slice); i += 4 {
36+ end := min(i+4, len(slice))
37+ m = append(m, slice[i:end])
38+ }
39+ return m
40+}
41+
42+// ShortHelp implements help.KeyMap.
43+func (k KeyMap) ShortHelp() []key.Binding {
44+ return []key.Binding{
45+ k.Tab,
46+ }
47+}
internal/tui/styles/crush.go+5−4
@@ -24,10 +24,11 @@ func NewCrushTheme() *Theme {
2424 BgOverlay: charmtone.Iron,
2525
2626 // Foregrounds
27− FgBase: charmtone.Ash,
28− FgMuted: charmtone.Squid,
29− FgSubtle: charmtone.Oyster,
30− FgSelected: charmtone.Salt,
27+ FgBase: charmtone.Ash,
28+ FgMuted: charmtone.Squid,
29+ FgHalfMuted: charmtone.Smoke,
30+ FgSubtle: charmtone.Oyster,
31+ FgSelected: charmtone.Salt,
3132
3233 // Borders
3334 Border: charmtone.Charcoal,
internal/tui/styles/theme.go+81−4
@@ -3,6 +3,7 @@ package styles
33import (
44 "fmt"
55 "image/color"
6+ "strings"
67
78 "github.com/charmbracelet/bubbles/v2/help"
89 "github.com/charmbracelet/bubbles/v2/textarea"
@@ -10,6 +11,8 @@ import (
1011 tea "github.com/charmbracelet/bubbletea/v2"
1112 "github.com/charmbracelet/glamour/v2/ansi"
1213 "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/lucasb-eyer/go-colorful"
15+ "github.com/rivo/uniseg"
1316)
1417
1518const (
@@ -35,10 +38,11 @@ type Theme struct {
3538 BgSubtle color.Color
3639 BgOverlay color.Color
3740
38− FgBase color.Color
39− FgMuted color.Color
40− FgSubtle color.Color
41− FgSelected color.Color
41+ FgBase color.Color
42+ FgMuted color.Color
43+ FgHalfMuted color.Color
44+ FgSubtle color.Color
45+ FgSelected color.Color
4246
4347 Border color.Color
4448 BorderFocus color.Color
@@ -491,3 +495,76 @@ func Lighten(c color.Color, percent float64) color.Color {
491495 A: uint8(a >> 8),
492496 }
493497}
498+
499+// ApplyForegroundGrad renders a given string with a horizontal gradient
500+// foreground.
501+func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
502+ if input == "" {
503+ return ""
504+ }
505+
506+ var o strings.Builder
507+ if len(input) == 1 {
508+ return lipgloss.NewStyle().Foreground(color1).Render(input)
509+ }
510+
511+ var clusters []string
512+ gr := uniseg.NewGraphemes(input)
513+ for gr.Next() {
514+ clusters = append(clusters, string(gr.Runes()))
515+ }
516+
517+ ramp := blendColors(len(clusters), color1, color2)
518+ for i, c := range ramp {
519+ fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
520+ }
521+
522+ return o.String()
523+}
524+
525+// blendColors returns a slice of colors blended between the given keys.
526+// Blending is done in Hcl to stay in gamut.
527+func blendColors(size int, stops ...color.Color) []color.Color {
528+ if len(stops) < 2 {
529+ return nil
530+ }
531+
532+ stopsPrime := make([]colorful.Color, len(stops))
533+ for i, k := range stops {
534+ stopsPrime[i], _ = colorful.MakeColor(k)
535+ }
536+
537+ numSegments := len(stopsPrime) - 1
538+ blended := make([]color.Color, 0, size)
539+
540+ // Calculate how many colors each segment should have.
541+ segmentSizes := make([]int, numSegments)
542+ baseSize := size / numSegments
543+ remainder := size % numSegments
544+
545+ // Distribute the remainder across segments.
546+ for i := range numSegments {
547+ segmentSizes[i] = baseSize
548+ if i < remainder {
549+ segmentSizes[i]++
550+ }
551+ }
552+
553+ // Generate colors for each segment.
554+ for i := range numSegments {
555+ c1 := stopsPrime[i]
556+ c2 := stopsPrime[i+1]
557+ segmentSize := segmentSizes[i]
558+
559+ for j := range segmentSize {
560+ var t float64
561+ if segmentSize > 1 {
562+ t = float64(j) / float64(segmentSize-1)
563+ }
564+ c := c1.BlendHcl(c2, t)
565+ blended = append(blended, c)
566+ }
567+ }
568+
569+ return blended
570+}
internal/tui/tui.go+3−2
@@ -12,6 +12,7 @@ import (
1212 cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
1313 "github.com/opencode-ai/opencode/internal/tui/components/completions"
1414 "github.com/opencode-ai/opencode/internal/tui/components/core"
15+ "github.com/opencode-ai/opencode/internal/tui/components/core/status"
1516 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1617 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
1718 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
@@ -34,7 +35,7 @@ type appModel struct {
3435 pages map[page.PageID]util.Model
3536 loadedPages map[page.PageID]bool
3637
37− status core.StatusCmp
38+ status status.StatusCmp
3839
3940 app *app.App
4041
@@ -288,7 +289,7 @@ func New(app *app.App) tea.Model {
288289 model := &appModel{
289290 currentPage: startPage,
290291 app: app,
291− status: core.NewStatusCmp(app.LSPClients),
292+ status: status.NewStatusCmp(),
292293 loadedPages: make(map[page.PageID]bool),
293294 keyMap: DefaultKeyMap(),
294295
todos.md+5
@@ -13,3 +13,8 @@
1313- [x] Sessions dialog
1414- [ ] Models
1515- [~] Move sessions and model dialog to the commands
16+
17+## Investigate
18+
19+- [ ] Events when tool error
20+- [ ] Fancy Spinner