1package dialog
2
3import (
4 "strings"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/opencode-ai/opencode/internal/tui/styles"
10 "github.com/opencode-ai/opencode/internal/tui/theme"
11 "github.com/opencode-ai/opencode/internal/tui/util"
12)
13
14type helpCmp struct {
15 width int
16 height int
17 keys []key.Binding
18}
19
20func (h *helpCmp) Init() tea.Cmd {
21 return nil
22}
23
24func (h *helpCmp) SetBindings(k []key.Binding) {
25 h.keys = k
26}
27
28func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29 switch msg := msg.(type) {
30 case tea.WindowSizeMsg:
31 h.width = 90
32 h.height = msg.Height
33 }
34 return h, nil
35}
36
37func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
38 seen := make(map[string]struct{})
39 result := make([]key.Binding, 0, len(bindings))
40
41 // Process bindings in reverse order
42 for i := len(bindings) - 1; i >= 0; i-- {
43 b := bindings[i]
44 k := strings.Join(b.Keys(), " ")
45 if _, ok := seen[k]; ok {
46 // duplicate, skip
47 continue
48 }
49 seen[k] = struct{}{}
50 // Add to the beginning of result to maintain original order
51 result = append([]key.Binding{b}, result...)
52 }
53
54 return result
55}
56
57func (h *helpCmp) render() string {
58 t := theme.CurrentTheme()
59 baseStyle := styles.BaseStyle()
60
61 helpKeyStyle := styles.Bold().
62 Background(t.Background()).
63 Foreground(t.Text()).
64 Padding(0, 1, 0, 0)
65
66 helpDescStyle := styles.Regular().
67 Background(t.Background()).
68 Foreground(t.TextMuted())
69
70 // Compile list of bindings to render
71 bindings := removeDuplicateBindings(h.keys)
72
73 // Enumerate through each group of bindings, populating a series of
74 // pairs of columns, one for keys, one for descriptions
75 var (
76 pairs []string
77 width int
78 rows = 12 - 2
79 )
80
81 for i := 0; i < len(bindings); i += rows {
82 var (
83 keys []string
84 descs []string
85 )
86 for j := i; j < min(i+rows, len(bindings)); j++ {
87 keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
88 descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
89 }
90
91 // Render pair of columns; beyond the first pair, render a three space
92 // left margin, in order to visually separate the pairs.
93 var cols []string
94 if len(pairs) > 0 {
95 cols = []string{baseStyle.Render(" ")}
96 }
97
98 maxDescWidth := 0
99 for _, desc := range descs {
100 if maxDescWidth < lipgloss.Width(desc) {
101 maxDescWidth = lipgloss.Width(desc)
102 }
103 }
104 for i := range descs {
105 remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
106 if remainingWidth > 0 {
107 descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
108 }
109 }
110 maxKeyWidth := 0
111 for _, key := range keys {
112 if maxKeyWidth < lipgloss.Width(key) {
113 maxKeyWidth = lipgloss.Width(key)
114 }
115 }
116 for i := range keys {
117 remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
118 if remainingWidth > 0 {
119 keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
120 }
121 }
122
123 cols = append(cols,
124 strings.Join(keys, "\n"),
125 strings.Join(descs, "\n"),
126 )
127
128 pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
129 // check whether it exceeds the maximum width avail (the width of the
130 // terminal, subtracting 2 for the borders).
131 width += lipgloss.Width(pair)
132 if width > h.width-2 {
133 break
134 }
135 pairs = append(pairs, pair)
136 }
137
138 // https://github.com/charmbracelet/lipgloss/v2/issues/209
139 if len(pairs) > 1 {
140 prefix := pairs[:len(pairs)-1]
141 lastPair := pairs[len(pairs)-1]
142 prefix = append(prefix, lipgloss.Place(
143 lipgloss.Width(lastPair), // width
144 lipgloss.Height(prefix[0]), // height
145 lipgloss.Left, // x
146 lipgloss.Top, // y
147 lastPair, // content
148 lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
149 ))
150 content := baseStyle.Width(h.width).Render(
151 lipgloss.JoinHorizontal(
152 lipgloss.Top,
153 prefix...,
154 ),
155 )
156 return content
157 }
158
159 // Join pairs of columns and enclose in a border
160 content := baseStyle.Width(h.width).Render(
161 lipgloss.JoinHorizontal(
162 lipgloss.Top,
163 pairs...,
164 ),
165 )
166 return content
167}
168
169func (h *helpCmp) View() tea.View {
170 t := theme.CurrentTheme()
171 baseStyle := styles.BaseStyle()
172
173 content := h.render()
174 header := baseStyle.
175 Bold(true).
176 Width(lipgloss.Width(content)).
177 Foreground(t.Primary()).
178 Render("Keyboard Shortcuts")
179
180 return tea.NewView(
181 baseStyle.Padding(1).
182 Border(lipgloss.RoundedBorder()).
183 BorderForeground(t.TextMuted()).
184 Width(h.width).
185 BorderBackground(t.Background()).
186 Render(
187 lipgloss.JoinVertical(lipgloss.Center,
188 header,
189 baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
190 content,
191 ),
192 ),
193 )
194}
195
196type HelpCmp interface {
197 util.Model
198 SetBindings([]key.Binding)
199}
200
201func NewHelpCmp() HelpCmp {
202 return &helpCmp{}
203}