on main help sign in
chore: improve help
1f16850Kujtim Hoxha
internal/tui/components/chat/editor/editor.go−3
@@ -157,9 +157,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157 var cmd tea.Cmd
157 var cmd tea.Cmd
158 var cmds []tea.Cmd
158 var cmds []tea.Cmd
159 switch msg := msg.(type) {
159 switch msg := msg.(type) {
160− case tea.KeyboardEnhancementsMsg:
161− m.keyMap.keyboard = msg
162− return m, nil
163 case filepicker.FilePickedMsg:
160 case filepicker.FilePickedMsg:
164 if len(m.attachments) >= maxAttachments {
161 if len(m.attachments) >= maxAttachments {
165 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
162 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
internal/tui/components/chat/editor/keys.go+1−8
@@ -2,7 +2,6 @@ package editor
2
2
3import (
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6)
5)
7
6
8type EditorKeyMap struct {
7type EditorKeyMap struct {
@@ -10,8 +9,6 @@ type EditorKeyMap struct {
10 SendMessage key.Binding
9 SendMessage key.Binding
11 OpenEditor key.Binding
10 OpenEditor key.Binding
12 Newline key.Binding
11 Newline key.Binding
13−
14− keyboard tea.KeyboardEnhancementsMsg
15}
12}
16
13
17func DefaultEditorKeyMap() EditorKeyMap {
14func DefaultEditorKeyMap() EditorKeyMap {
@@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap {
40
37
41// KeyBindings implements layout.KeyMapProvider
38// KeyBindings implements layout.KeyMapProvider
42func (k EditorKeyMap) KeyBindings() []key.Binding {
39func (k EditorKeyMap) KeyBindings() []key.Binding {
43− newline := k.Newline
44− if k.keyboard.SupportsKeyDisambiguation() {
45− newline.SetHelp("shift+enter", newline.Help().Desc)
46− }
47 return []key.Binding{
40 return []key.Binding{
48 k.AddFile,
41 k.AddFile,
49 k.SendMessage,
42 k.SendMessage,
50 k.OpenEditor,
43 k.OpenEditor,
51− newline,
44+ k.Newline,
52 }
45 }
53}
46}
54
47
internal/tui/components/chat/splash/splash.go+11−2
@@ -32,6 +32,9 @@ type Splash interface {
32 SetOnboarding(bool)
32 SetOnboarding(bool)
33 // SetProjectInit controls whether the splash shows project initialization prompt
33 // SetProjectInit controls whether the splash shows project initialization prompt
34 SetProjectInit(bool)
34 SetProjectInit(bool)
35+
36+ // Showing API key input
37+ IsShowingAPIKey() bool
35}
38}
36
39
37const (
40const (
@@ -126,9 +129,11 @@ func (s *splashCmp) Init() tea.Cmd {
126
129
127// SetSize implements SplashPage.
130// SetSize implements SplashPage.
128func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
131func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
129− s.width = width
130 s.height = height
132 s.height = height
133+ if width != s.width {
134+ s.width = width
131− s.logoRendered = s.logoBlock()
135+ s.logoRendered = s.logoBlock()
136+ }
132 listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title
137 listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title
133 listWidth := min(60, width-(SplashScreenPaddingX*2))
138 listWidth := min(60, width-(SplashScreenPaddingX*2))
134
139
@@ -626,3 +631,7 @@ func (s *splashCmp) mcpBlock() string {
626 ),
631 ),
627 )
632 )
628}
633}
634+
635+func (s *splashCmp) IsShowingAPIKey() bool {
636+ return s.needsAPIKey
637+}
internal/tui/components/core/helpers.go → internal/tui/components/core/core.gorenamed+28
@@ -5,12 +5,40 @@ import (
5 "strings"
5 "strings"
6
6
7 "github.com/alecthomas/chroma/v2"
7 "github.com/alecthomas/chroma/v2"
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
10 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
9 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/ansi"
12)
14)
13
15
16+type KeyMapHelp interface {
17+ Help() help.KeyMap
18+}
19+
20+type simpleHelp struct {
21+ shortList []key.Binding
22+ fullList [][]key.Binding
23+}
24+
25+func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap {
26+ return &simpleHelp{
27+ shortList: shortList,
28+ fullList: fullList,
29+ }
30+}
31+
32+// FullHelp implements help.KeyMap.
33+func (s *simpleHelp) FullHelp() [][]key.Binding {
34+ return s.fullList
35+}
36+
37+// ShortHelp implements help.KeyMap.
38+func (s *simpleHelp) ShortHelp() []key.Binding {
39+ return s.shortList
40+}
41+
14func Section(text string, width int) string {
42func Section(text string, width int) string {
15 t := styles.CurrentTheme()
43 t := styles.CurrentTheme()
16 char := "─"
44 char := "─"
internal/tui/components/core/layout/container.godeleted−263
@@ -1,263 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/tui/styles"
7− "github.com/charmbracelet/crush/internal/tui/util"
8− "github.com/charmbracelet/lipgloss/v2"
9−)
10−
11−type Container interface {
12− util.Model
13− Sizeable
14− Help
15− Positional
16− Focusable
17−}
18−type container struct {
19− width int
20− height int
21− isFocused bool
22−
23− x, y int
24−
25− content util.Model
26−
27− // Style options
28− paddingTop int
29− paddingRight int
30− paddingBottom int
31− paddingLeft int
32−
33− borderTop bool
34− borderRight bool
35− borderBottom bool
36− borderLeft bool
37− borderStyle lipgloss.Border
38−}
39−
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−
55−func (c *container) Init() tea.Cmd {
56− return c.content.Init()
57−}
58−
59−func (c *container) Update(msg tea.Msg) (tea.Model, tea.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− }
73−}
74−
75−func (c *container) Cursor() *tea.Cursor {
76− if cursor, ok := c.content.(util.Cursor); ok {
77− return cursor.Cursor()
78− }
79− return nil
80−}
81−
82−func (c *container) View() string {
83− t := styles.CurrentTheme()
84− width := c.width
85− height := c.height
86−
87− style := t.S().Base
88−
89− // Apply border if any side is enabled
90− if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
91− // Adjust width and height for borders
92− if c.borderTop {
93− height--
94− }
95− if c.borderBottom {
96− height--
97− }
98− if c.borderLeft {
99− width--
100− }
101− if c.borderRight {
102− width--
103− }
104− style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
105− style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
106− }
107− style = style.
108− Width(width).
109− Height(height).
110− PaddingTop(c.paddingTop).
111− PaddingRight(c.paddingRight).
112− PaddingBottom(c.paddingBottom).
113− PaddingLeft(c.paddingLeft)
114−
115− contentView := c.content.View()
116− return style.Render(contentView)
117−}
118−
119−func (c *container) SetSize(width, height int) tea.Cmd {
120− c.width = width
121− c.height = height
122−
123− // If the content implements Sizeable, adjust its size to account for padding and borders
124− if sizeable, ok := c.content.(Sizeable); ok {
125− // Calculate horizontal space taken by padding and borders
126− horizontalSpace := c.paddingLeft + c.paddingRight
127− if c.borderLeft {
128− horizontalSpace++
129− }
130− if c.borderRight {
131− horizontalSpace++
132− }
133−
134− // Calculate vertical space taken by padding and borders
135− verticalSpace := c.paddingTop + c.paddingBottom
136− if c.borderTop {
137− verticalSpace++
138− }
139− if c.borderBottom {
140− verticalSpace++
141− }
142−
143− // Set content size with adjusted dimensions
144− contentWidth := max(0, width-horizontalSpace)
145− contentHeight := max(0, height-verticalSpace)
146− return sizeable.SetSize(contentWidth, contentHeight)
147− }
148− return nil
149−}
150−
151−func (c *container) GetSize() (int, int) {
152− return c.width, c.height
153−}
154−
155−func (c *container) SetPosition(x, y int) tea.Cmd {
156− c.x = x
157− c.y = y
158− if positionable, ok := c.content.(Positional); ok {
159− return positionable.SetPosition(x, y)
160− }
161− return nil
162−}
163−
164−func (c *container) Bindings() []key.Binding {
165− if b, ok := c.content.(Help); ok {
166− return b.Bindings()
167− }
168− return nil
169−}
170−
171−// Blur implements Container.
172−func (c *container) Blur() tea.Cmd {
173− c.isFocused = false
174− if focusable, ok := c.content.(Focusable); ok {
175− return focusable.Blur()
176− }
177− return nil
178−}
179−
180−// Focus implements Container.
181−func (c *container) Focus() tea.Cmd {
182− c.isFocused = true
183− if focusable, ok := c.content.(Focusable); ok {
184− return focusable.Focus()
185− }
186− return nil
187−}
188−
189−// IsFocused implements Container.
190−func (c *container) IsFocused() bool {
191− isFocused := c.isFocused
192− if focusable, ok := c.content.(Focusable); ok {
193− isFocused = isFocused || focusable.IsFocused()
194− }
195− return isFocused
196−}
197−
198−// Padding options
199−func WithPadding(top, right, bottom, left int) ContainerOption {
200− return func(c *container) {
201− c.paddingTop = top
202− c.paddingRight = right
203− c.paddingBottom = bottom
204− c.paddingLeft = left
205− }
206−}
207−
208−func WithPaddingAll(padding int) ContainerOption {
209− return WithPadding(padding, padding, padding, padding)
210−}
211−
212−func WithPaddingHorizontal(padding int) ContainerOption {
213− return func(c *container) {
214− c.paddingLeft = padding
215− c.paddingRight = padding
216− }
217−}
218−
219−func WithPaddingVertical(padding int) ContainerOption {
220− return func(c *container) {
221− c.paddingTop = padding
222− c.paddingBottom = padding
223− }
224−}
225−
226−func WithBorder(top, right, bottom, left bool) ContainerOption {
227− return func(c *container) {
228− c.borderTop = top
229− c.borderRight = right
230− c.borderBottom = bottom
231− c.borderLeft = left
232− }
233−}
234−
235−func WithBorderAll() ContainerOption {
236− return WithBorder(true, true, true, true)
237−}
238−
239−func WithBorderHorizontal() ContainerOption {
240− return WithBorder(true, false, true, false)
241−}
242−
243−func WithBorderVertical() ContainerOption {
244− return WithBorder(false, true, false, true)
245−}
246−
247−func WithBorderStyle(style lipgloss.Border) ContainerOption {
248− return func(c *container) {
249− c.borderStyle = style
250− }
251−}
252−
253−func WithRoundedBorder() ContainerOption {
254− return WithBorderStyle(lipgloss.RoundedBorder())
255−}
256−
257−func WithThickBorder() ContainerOption {
258− return WithBorderStyle(lipgloss.ThickBorder())
259−}
260−
261−func WithDoubleBorder() ContainerOption {
262− return WithBorderStyle(lipgloss.DoubleBorder())
263−}
internal/tui/components/core/layout/layout.go+2−5
@@ -5,6 +5,8 @@ import (
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6)
6)
7
7
8+// TODO: move this to core
9+
8type Focusable interface {
10type Focusable interface {
9 Focus() tea.Cmd
11 Focus() tea.Cmd
10 Blur() tea.Cmd
12 Blur() tea.Cmd
@@ -23,8 +25,3 @@ type Help interface {
23type Positional interface {
25type Positional interface {
24 SetPosition(x, y int) tea.Cmd
26 SetPosition(x, y int) tea.Cmd
25}
27}
26−
27−// KeyMapProvider defines an interface for types that can provide their key bindings as a slice
28−type KeyMapProvider interface {
29− KeyBindings() []key.Binding
30−}
internal/tui/components/core/layout/split.godeleted−375
@@ -1,375 +0,0 @@
1−package layout
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/tui/styles"
7− "github.com/charmbracelet/crush/internal/tui/util"
8− "github.com/charmbracelet/lipgloss/v2"
9−)
10−
11−type LayoutPanel string
12−
13−const (
14− LeftPanel LayoutPanel = "left"
15− RightPanel LayoutPanel = "right"
16− BottomPanel LayoutPanel = "bottom"
17−)
18−
19−type SplitPaneLayout interface {
20− util.Model
21− Sizeable
22− Help
23− SetLeftPanel(panel Container) tea.Cmd
24− SetRightPanel(panel Container) tea.Cmd
25− SetBottomPanel(panel Container) tea.Cmd
26−
27− ClearLeftPanel() tea.Cmd
28− ClearRightPanel() tea.Cmd
29− ClearBottomPanel() tea.Cmd
30−
31− FocusPanel(panel LayoutPanel) tea.Cmd
32− SetOffset(x, y int)
33−}
34−
35−type splitPaneLayout struct {
36− width int
37− height int
38− xOffset int
39− yOffset int
40−
41− ratio float64
42− verticalRatio float64
43−
44− rightPanel Container
45− leftPanel Container
46− bottomPanel Container
47−
48− fixedBottomHeight int // Fixed height for the bottom panel, if any
49− fixedRightWidth int // Fixed width for the right panel, if any
50−}
51−
52−type SplitPaneOption func(*splitPaneLayout)
53−
54−func (s *splitPaneLayout) Init() tea.Cmd {
55− var cmds []tea.Cmd
56−
57− if s.leftPanel != nil {
58− cmds = append(cmds, s.leftPanel.Init())
59− }
60−
61− if s.rightPanel != nil {
62− cmds = append(cmds, s.rightPanel.Init())
63− }
64−
65− if s.bottomPanel != nil {
66− cmds = append(cmds, s.bottomPanel.Init())
67− }
68−
69− return tea.Batch(cmds...)
70−}
71−
72−func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73− var cmds []tea.Cmd
74− switch msg := msg.(type) {
75− case tea.WindowSizeMsg:
76− return s, s.SetSize(msg.Width, msg.Height)
77− }
78−
79− if s.rightPanel != nil {
80− u, cmd := s.rightPanel.Update(msg)
81− s.rightPanel = u.(Container)
82− if cmd != nil {
83− cmds = append(cmds, cmd)
84− }
85− }
86−
87− if s.leftPanel != nil {
88− u, cmd := s.leftPanel.Update(msg)
89− s.leftPanel = u.(Container)
90− if cmd != nil {
91− cmds = append(cmds, cmd)
92− }
93− }
94−
95− if s.bottomPanel != nil {
96− u, cmd := s.bottomPanel.Update(msg)
97− s.bottomPanel = u.(Container)
98− if cmd != nil {
99− cmds = append(cmds, cmd)
100− }
101− }
102−
103− return s, tea.Batch(cmds...)
104−}
105−
106−func (s *splitPaneLayout) Cursor() *tea.Cursor {
107− if s.bottomPanel != nil {
108− if c, ok := s.bottomPanel.(util.Cursor); ok {
109− return c.Cursor()
110− }
111− } else if s.rightPanel != nil {
112− if c, ok := s.rightPanel.(util.Cursor); ok {
113− return c.Cursor()
114− }
115− } else if s.leftPanel != nil {
116− if c, ok := s.leftPanel.(util.Cursor); ok {
117− return c.Cursor()
118− }
119− }
120− return nil
121−}
122−
123−func (s *splitPaneLayout) View() string {
124− var topSection string
125−
126− if s.leftPanel != nil && s.rightPanel != nil {
127− leftView := s.leftPanel.View()
128− rightView := s.rightPanel.View()
129− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
130− } else if s.leftPanel != nil {
131− topSection = s.leftPanel.View()
132− } else if s.rightPanel != nil {
133− topSection = s.rightPanel.View()
134− } else {
135− topSection = ""
136− }
137−
138− var finalView string
139−
140− if s.bottomPanel != nil && topSection != "" {
141− bottomView := s.bottomPanel.View()
142− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
143− } else if s.bottomPanel != nil {
144− finalView = s.bottomPanel.View()
145− } else {
146− finalView = topSection
147− }
148−
149− t := styles.CurrentTheme()
150−
151− style := t.S().Base.
152− Width(s.width).
153− Height(s.height)
154−
155− return style.Render(finalView)
156−}
157−
158−func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
159− s.width = width
160− s.height = height
161− var topHeight, bottomHeight int
162− var cmds []tea.Cmd
163− if s.bottomPanel != nil {
164− if s.fixedBottomHeight > 0 {
165− bottomHeight = s.fixedBottomHeight
166− topHeight = height - bottomHeight
167− } else {
168− topHeight = int(float64(height) * s.verticalRatio)
169− bottomHeight = height - topHeight
170− if bottomHeight <= 0 {
171− bottomHeight = 2
172− topHeight = height - bottomHeight
173− }
174− }
175− } else {
176− topHeight = height
177− bottomHeight = 0
178− }
179−
180− var leftWidth, rightWidth int
181− if s.leftPanel != nil && s.rightPanel != nil {
182− if s.fixedRightWidth > 0 {
183− rightWidth = s.fixedRightWidth
184− leftWidth = width - rightWidth
185− } else {
186− leftWidth = int(float64(width) * s.ratio)
187− rightWidth = width - leftWidth
188− if rightWidth <= 0 {
189− rightWidth = 2
190− leftWidth = width - rightWidth
191− }
192− }
193− } else if s.leftPanel != nil {
194− leftWidth = width
195− rightWidth = 0
196− } else if s.rightPanel != nil {
197− leftWidth = 0
198− rightWidth = width
199− }
200−
201− if s.leftPanel != nil {
202− cmd := s.leftPanel.SetSize(leftWidth, topHeight)
203− cmds = append(cmds, cmd)
204− if positional, ok := s.leftPanel.(Positional); ok {
205− cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
206− }
207− }
208−
209− if s.rightPanel != nil {
210− cmd := s.rightPanel.SetSize(rightWidth, topHeight)
211− cmds = append(cmds, cmd)
212− if positional, ok := s.rightPanel.(Positional); ok {
213− cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
214− }
215− }
216−
217− if s.bottomPanel != nil {
218− cmd := s.bottomPanel.SetSize(width, bottomHeight)
219− cmds = append(cmds, cmd)
220− if positional, ok := s.bottomPanel.(Positional); ok {
221− cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
222− }
223− }
224− return tea.Batch(cmds...)
225−}
226−
227−func (s *splitPaneLayout) GetSize() (int, int) {
228− return s.width, s.height
229−}
230−
231−func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
232− s.leftPanel = panel
233− if s.width > 0 && s.height > 0 {
234− return s.SetSize(s.width, s.height)
235− }
236− return nil
237−}
238−
239−func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
240− s.rightPanel = panel
241− if s.width > 0 && s.height > 0 {
242− return s.SetSize(s.width, s.height)
243− }
244− return nil
245−}
246−
247−func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
248− s.bottomPanel = panel
249− if s.width > 0 && s.height > 0 {
250− return s.SetSize(s.width, s.height)
251− }
252− return nil
253−}
254−
255−func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
256− s.leftPanel = nil
257− if s.width > 0 && s.height > 0 {
258− return s.SetSize(s.width, s.height)
259− }
260− return nil
261−}
262−
263−func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
264− s.rightPanel = nil
265− if s.width > 0 && s.height > 0 {
266− return s.SetSize(s.width, s.height)
267− }
268− return nil
269−}
270−
271−func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
272− s.bottomPanel = nil
273− if s.width > 0 && s.height > 0 {
274− return s.SetSize(s.width, s.height)
275− }
276− return nil
277−}
278−
279−func (s *splitPaneLayout) Bindings() []key.Binding {
280− if s.leftPanel != nil {
281− if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
282− return b.Bindings()
283− }
284− }
285− if s.rightPanel != nil {
286− if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
287− return b.Bindings()
288− }
289− }
290− if s.bottomPanel != nil {
291− if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
292− return b.Bindings()
293− }
294− }
295− return nil
296−}
297−
298−func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
299− panels := map[LayoutPanel]Container{
300− LeftPanel: s.leftPanel,
301− RightPanel: s.rightPanel,
302− BottomPanel: s.bottomPanel,
303− }
304− var cmds []tea.Cmd
305− for p, container := range panels {
306− if container == nil {
307− continue
308− }
309− if p == panel {
310− cmds = append(cmds, container.Focus())
311− } else {
312− cmds = append(cmds, container.Blur())
313− }
314− }
315− return tea.Batch(cmds...)
316−}
317−
318−// SetOffset implements SplitPaneLayout.
319−func (s *splitPaneLayout) SetOffset(x int, y int) {
320− s.xOffset = x
321− s.yOffset = y
322−}
323−
324−func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
325− layout := &splitPaneLayout{
326− ratio: 0.8,
327− verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
328− }
329− for _, option := range options {
330− option(layout)
331− }
332− return layout
333−}
334−
335−func WithLeftPanel(panel Container) SplitPaneOption {
336− return func(s *splitPaneLayout) {
337− s.leftPanel = panel
338− }
339−}
340−
341−func WithRightPanel(panel Container) SplitPaneOption {
342− return func(s *splitPaneLayout) {
343− s.rightPanel = panel
344− }
345−}
346−
347−func WithRatio(ratio float64) SplitPaneOption {
348− return func(s *splitPaneLayout) {
349− s.ratio = ratio
350− }
351−}
352−
353−func WithBottomPanel(panel Container) SplitPaneOption {
354− return func(s *splitPaneLayout) {
355− s.bottomPanel = panel
356− }
357−}
358−
359−func WithVerticalRatio(ratio float64) SplitPaneOption {
360− return func(s *splitPaneLayout) {
361− s.verticalRatio = ratio
362− }
363−}
364−
365−func WithFixedBottomHeight(height int) SplitPaneOption {
366− return func(s *splitPaneLayout) {
367− s.fixedBottomHeight = height
368− }
369−}
370−
371−func WithFixedRightWidth(width int) SplitPaneOption {
372− return func(s *splitPaneLayout) {
373− s.fixedRightWidth = width
374− }
375−}
internal/tui/components/core/list/keys.go+10−1
@@ -9,6 +9,8 @@ type KeyMap struct {
9 Up,
9 Up,
10 DownOneItem,
10 DownOneItem,
11 UpOneItem,
11 UpOneItem,
12+ PageDown,
13+ PageUp,
12 HalfPageDown,
14 HalfPageDown,
13 HalfPageUp,
15 HalfPageUp,
14 Home,
16 Home,
@@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap {
37 key.WithKeys("d"),
39 key.WithKeys("d"),
38 key.WithHelp("d", "half page down"),
40 key.WithHelp("d", "half page down"),
39 ),
41 ),
42+ PageDown: key.NewBinding(
43+ key.WithKeys("pgdown", " ", "f"),
44+ key.WithHelp("f/pgdn", "page down"),
45+ ),
46+ PageUp: key.NewBinding(
47+ key.WithKeys("pgup", "b"),
48+ key.WithHelp("b/pgup", "page up"),
40− HalfPageUp: key.NewBinding(
49+ ), HalfPageUp: key.NewBinding(
41 key.WithKeys("u"),
50 key.WithKeys("u"),
42 key.WithHelp("u", "half page up"),
51 key.WithHelp("u", "half page up"),
43 ),
52 ),
internal/tui/components/core/list/list.go+4
@@ -332,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
332 m.scrollDown(m.listHeight() / 2)
332 m.scrollDown(m.listHeight() / 2)
333 case key.Matches(msg, m.keyMap.HalfPageUp):
333 case key.Matches(msg, m.keyMap.HalfPageUp):
334 m.scrollUp(m.listHeight() / 2)
334 m.scrollUp(m.listHeight() / 2)
335+ case key.Matches(msg, m.keyMap.PageDown):
336+ m.scrollDown(m.listHeight())
337+ case key.Matches(msg, m.keyMap.PageUp):
338+ m.scrollUp(m.listHeight())
335 case key.Matches(msg, m.keyMap.Home):
339 case key.Matches(msg, m.keyMap.Home):
336 return m, m.goToTop()
340 return m, m.goToTop()
337 case key.Matches(msg, m.keyMap.End):
341 case key.Matches(msg, m.keyMap.End):
internal/tui/components/core/status/status.go+1−2
@@ -98,13 +98,12 @@ func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
98 m.keyMap = keyMap
98 m.keyMap = keyMap
99}
99}
100
100
101−func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
101+func NewStatusCmp() StatusCmp {
102 t := styles.CurrentTheme()
102 t := styles.CurrentTheme()
103 help := help.New()
103 help := help.New()
104 help.Styles = t.S().Help
104 help.Styles = t.S().Help
105 return &statusCmp{
105 return &statusCmp{
106 messageTTL: 5 * time.Second,
106 messageTTL: 5 * time.Second,
107 help: help,
107 help: help,
108− keyMap: keyMap,
109 }
108 }
110}
109}
internal/tui/keys.go+2−61
@@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap {
20 key.WithHelp("ctrl+c", "quit"),
20 key.WithHelp("ctrl+c", "quit"),
21 ),
21 ),
22 Help: key.NewBinding(
22 Help: key.NewBinding(
23− key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
23+ key.WithKeys("ctrl+g"),
24− key.WithHelp("ctrl+?", "more"),
24+ key.WithHelp("ctrl+g", "more"),
25 ),
25 ),
26 Commands: key.NewBinding(
26 Commands: key.NewBinding(
27 key.WithKeys("ctrl+p"),
27 key.WithKeys("ctrl+p"),
@@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap {
33 ),
33 ),
34 }
34 }
35}
35}
36−
37−// FullHelp implements help.KeyMap.
38−func (k KeyMap) FullHelp() [][]key.Binding {
39− m := [][]key.Binding{}
40− slice := []key.Binding{
41− k.Commands,
42− k.Sessions,
43− k.Quit,
44− k.Help,
45− }
46− slice = k.prependEscAndTab(slice)
47− slice = append(slice, k.pageBindings...)
48− // remove duplicates
49− seen := make(map[string]bool)
50− cleaned := []key.Binding{}
51− for _, b := range slice {
52− if !seen[b.Help().Key] {
53− seen[b.Help().Key] = true
54− cleaned = append(cleaned, b)
55− }
56− }
57−
58− for i := 0; i < len(cleaned); i += 3 {
59− end := min(i+3, len(cleaned))
60− m = append(m, cleaned[i:end])
61− }
62− return m
63−}
64−
65−func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding {
66− var cancel key.Binding
67− var tab key.Binding
68− for _, b := range k.pageBindings {
69− if b.Help().Key == "esc" {
70− cancel = b
71− }
72− if b.Help().Key == "tab" {
73− tab = b
74− }
75− }
76− if tab.Help().Key != "" {
77− bindings = append([]key.Binding{tab}, bindings...)
78− }
79− if cancel.Help().Key != "" {
80− bindings = append([]key.Binding{cancel}, bindings...)
81− }
82− return bindings
83−}
84−
85−// ShortHelp implements help.KeyMap.
86−func (k KeyMap) ShortHelp() []key.Binding {
87− bindings := []key.Binding{
88− k.Commands,
89− k.Sessions,
90− k.Quit,
91− k.Help,
92− }
93− return k.prependEscAndTab(bindings)
94−}
internal/tui/page/chat/chat.go+237−3
@@ -2,8 +2,10 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5+ "runtime"
5 "time"
6 "time"
6
7
8+ "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/spinner"
10 "github.com/charmbracelet/bubbles/v2/spinner"
9 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
@@ -20,6 +22,7 @@ import (
20 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
22 "github.com/charmbracelet/crush/internal/tui/components/completions"
24 "github.com/charmbracelet/crush/internal/tui/components/completions"
25+ "github.com/charmbracelet/crush/internal/tui/components/core"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
@@ -82,6 +85,7 @@ type chatPage struct {
82 width, height int
85 width, height int
83 detailsWidth, detailsHeight int
86 detailsWidth, detailsHeight int
84 app *app.App
87 app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
85
89
86 // Layout state
90 // Layout state
87 compact bool
91 compact bool
@@ -103,6 +107,8 @@ type chatPage struct {
103 showingDetails bool
107 showingDetails bool
104 isCanceling bool
108 isCanceling bool
105 splashFullScreen bool
109 splashFullScreen bool
110+ isOnboarding bool
111+ isProjectInit bool
106}
112}
107
113
108func New(app *app.App) ChatPage {
114func New(app *app.App) ChatPage {
@@ -129,10 +135,12 @@ func (p *chatPage) Init() tea.Cmd {
129 if !config.HasInitialDataConfig() {
135 if !config.HasInitialDataConfig() {
130 // First-time setup: show model selection
136 // First-time setup: show model selection
131 p.splash.SetOnboarding(true)
137 p.splash.SetOnboarding(true)
138+ p.isOnboarding = true
132 p.splashFullScreen = true
139 p.splashFullScreen = true
133 } else if b, _ := config.ProjectNeedsInitialization(); b {
140 } else if b, _ := config.ProjectNeedsInitialization(); b {
134 // Project needs CRUSH.md initialization
141 // Project needs CRUSH.md initialization
135 p.splash.SetProjectInit(true)
142 p.splash.SetProjectInit(true)
143+ p.isProjectInit = true
136 p.splashFullScreen = true
144 p.splashFullScreen = true
137 } else {
145 } else {
138 // Ready to chat: focus editor, splash in background
146 // Ready to chat: focus editor, splash in background
@@ -153,9 +161,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153 var cmds []tea.Cmd
161 var cmds []tea.Cmd
154 switch msg := msg.(type) {
162 switch msg := msg.(type) {
155 case tea.KeyboardEnhancementsMsg:
163 case tea.KeyboardEnhancementsMsg:
156− m, cmd := p.editor.Update(msg)
164+ p.keyboardEnhancements = msg
157− p.editor = m.(editor.Editor)
158− return p, cmd
165+ return p, nil
159 case tea.WindowSizeMsg:
166 case tea.WindowSizeMsg:
160 return p, p.SetSize(msg.Width, msg.Height)
167 return p, p.SetSize(msg.Width, msg.Height)
161 case CancelTimerExpiredMsg:
168 case CancelTimerExpiredMsg:
@@ -237,6 +244,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237 if err != nil {
244 if err != nil {
238 return p, util.ReportError(err)
245 return p, util.ReportError(err)
239 }
246 }
247+ p.isOnboarding = false
248+ p.isProjectInit = false
240 p.focusedPane = PanelTypeEditor
249 p.focusedPane = PanelTypeEditor
241 return p, p.SetSize(p.width, p.height)
250 return p, p.SetSize(p.width, p.height)
242 case tea.KeyPressMsg:
251 case tea.KeyPressMsg:
@@ -579,6 +588,231 @@ func (p *chatPage) Bindings() []key.Binding {
579 return bindings
588 return bindings
580}
589}
581
590
591+func (a *chatPage) Help() help.KeyMap {
592+ var shortList []key.Binding
593+ var fullList [][]key.Binding
594+ switch {
595+ case a.isOnboarding && !a.splash.IsShowingAPIKey():
596+ shortList = append(shortList,
597+ // Choose model
598+ key.NewBinding(
599+ key.WithKeys("up", "down"),
600+ key.WithHelp("↑/↓", "choose"),
601+ ),
602+ // Accept selection
603+ key.NewBinding(
604+ key.WithKeys("enter", "ctrl+y"),
605+ key.WithHelp("enter", "accept"),
606+ ),
607+ // Quit
608+ key.NewBinding(
609+ key.WithKeys("ctrl+c"),
610+ key.WithHelp("ctrl+c", "quit"),
611+ ),
612+ )
613+ // keep them the same
614+ for _, v := range shortList {
615+ fullList = append(fullList, []key.Binding{v})
616+ }
617+ case a.isOnboarding && a.splash.IsShowingAPIKey():
618+ var pasteKey key.Binding
619+ if runtime.GOOS != "darwin" {
620+ pasteKey = key.NewBinding(
621+ key.WithKeys("ctrl+v"),
622+ key.WithHelp("ctrl+v", "paste API key"),
623+ )
624+ } else {
625+ pasteKey = key.NewBinding(
626+ key.WithKeys("cmd+v"),
627+ key.WithHelp("cmd+v", "paste API key"),
628+ )
629+ }
630+ shortList = append(shortList,
631+ // Go back
632+ key.NewBinding(
633+ key.WithKeys("esc"),
634+ key.WithHelp("esc", "back"),
635+ ),
636+ // Paste
637+ pasteKey,
638+ // Quit
639+ key.NewBinding(
640+ key.WithKeys("ctrl+c"),
641+ key.WithHelp("ctrl+c", "quit"),
642+ ),
643+ )
644+ // keep them the same
645+ for _, v := range shortList {
646+ fullList = append(fullList, []key.Binding{v})
647+ }
648+ case a.isProjectInit:
649+ shortList = append(shortList,
650+ key.NewBinding(
651+ key.WithKeys("ctrl+c"),
652+ key.WithHelp("ctrl+c", "quit"),
653+ ),
654+ )
655+ // keep them the same
656+ for _, v := range shortList {
657+ fullList = append(fullList, []key.Binding{v})
658+ }
659+ default:
660+ if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
661+ cancelBinding := key.NewBinding(
662+ key.WithKeys("esc"),
663+ key.WithHelp("esc", "cancel"),
664+ )
665+ if a.isCanceling {
666+ cancelBinding = key.NewBinding(
667+ key.WithKeys("esc"),
668+ key.WithHelp("esc", "press again to cancel"),
669+ )
670+ }
671+ shortList = append(shortList, cancelBinding)
672+ fullList = append(fullList,
673+ []key.Binding{
674+ cancelBinding,
675+ },
676+ )
677+ }
678+ globalBindings := []key.Binding{}
679+ // we are in a session
680+ if a.session.ID != "" {
681+ tabKey := key.NewBinding(
682+ key.WithKeys("tab"),
683+ key.WithHelp("tab", "focus chat"),
684+ )
685+ if a.focusedPane == PanelTypeChat {
686+ tabKey = key.NewBinding(
687+ key.WithKeys("tab"),
688+ key.WithHelp("tab", "focus editor"),
689+ )
690+ }
691+ shortList = append(shortList, tabKey)
692+ globalBindings = append(globalBindings, tabKey)
693+ }
694+ commandsBinding := key.NewBinding(
695+ key.WithKeys("ctrl+p"),
696+ key.WithHelp("ctrl+p", "commands"),
697+ )
698+ helpBinding := key.NewBinding(
699+ key.WithKeys("ctrl+g"),
700+ key.WithHelp("ctrl+g", "more"),
701+ )
702+ globalBindings = append(globalBindings, commandsBinding)
703+ globalBindings = append(globalBindings,
704+ key.NewBinding(
705+ key.WithKeys("ctrl+s"),
706+ key.WithHelp("ctrl+s", "sessions"),
707+ ),
708+ )
709+ if a.session.ID != "" {
710+ globalBindings = append(globalBindings,
711+ key.NewBinding(
712+ key.WithKeys("ctrl+n"),
713+ key.WithHelp("ctrl+n", "new sessions"),
714+ ))
715+ }
716+ shortList = append(shortList,
717+ // Commands
718+ commandsBinding,
719+ )
720+ fullList = append(fullList, globalBindings)
721+
722+ if a.focusedPane == PanelTypeChat {
723+ shortList = append(shortList,
724+ key.NewBinding(
725+ key.WithKeys("up", "down"),
726+ key.WithHelp("↑↓", "scroll"),
727+ ),
728+ )
729+ fullList = append(fullList,
730+ []key.Binding{
731+ key.NewBinding(
732+ key.WithKeys("up", "down"),
733+ key.WithHelp("↑↓", "scroll"),
734+ ),
735+ key.NewBinding(
736+ key.WithKeys("shift+up", "shift+down"),
737+ key.WithHelp("shift+↑↓", "next/prev item"),
738+ ),
739+ key.NewBinding(
740+ key.WithKeys("pgup", "b"),
741+ key.WithHelp("b/pgup", "page up"),
742+ ),
743+ key.NewBinding(
744+ key.WithKeys("pgdown", " ", "f"),
745+ key.WithHelp("f/pgdn", "page down"),
746+ ),
747+ },
748+ []key.Binding{
749+ key.NewBinding(
750+ key.WithKeys("u"),
751+ key.WithHelp("u", "half page up"),
752+ ),
753+ key.NewBinding(
754+ key.WithKeys("d"),
755+ key.WithHelp("d", "half page down"),
756+ ),
757+ key.NewBinding(
758+ key.WithKeys("g", "home"),
759+ key.WithHelp("g", "hone"),
760+ ),
761+ key.NewBinding(
762+ key.WithKeys("G", "end"),
763+ key.WithHelp("G", "end"),
764+ ),
765+ },
766+ )
767+ } else if a.focusedPane == PanelTypeEditor {
768+ newLineBinding := key.NewBinding(
769+ key.WithKeys("shift+enter", "ctrl+j"),
770+ // "ctrl+j" is a common keybinding for newline in many editors. If
771+ // the terminal supports "shift+enter", we substitute the help text
772+ // to reflect that.
773+ key.WithHelp("ctrl+j", "newline"),
774+ )
775+ if a.keyboardEnhancements.SupportsKeyDisambiguation() {
776+ newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
777+ }
778+ shortList = append(shortList, newLineBinding)
779+ fullList = append(fullList,
780+ []key.Binding{
781+ newLineBinding,
782+ key.NewBinding(
783+ key.WithKeys("ctrl+f"),
784+ key.WithHelp("ctrl+f", "add image"),
785+ ),
786+ key.NewBinding(
787+ key.WithKeys("/"),
788+ key.WithHelp("/", "add file"),
789+ ),
790+ key.NewBinding(
791+ key.WithKeys("ctrl+e"),
792+ key.WithHelp("ctrl+e", "open editor"),
793+ ),
794+ })
795+ }
796+ shortList = append(shortList,
797+ // Quit
798+ key.NewBinding(
799+ key.WithKeys("ctrl+c"),
800+ key.WithHelp("ctrl+c", "quit"),
801+ ),
802+ // Help
803+ helpBinding,
804+ )
805+ fullList = append(fullList, []key.Binding{
806+ key.NewBinding(
807+ key.WithKeys("ctrl+g"),
808+ key.WithHelp("ctrl+g", "less"),
809+ ),
810+ })
811+ }
812+
813+ return core.NewSimpleHelp(shortList, fullList)
814+}
815+
582func (p *chatPage) IsChatFocused() bool {
816func (p *chatPage) IsChatFocused() bool {
583 return p.focusedPane == PanelTypeChat
817 return p.focusedPane == PanelTypeChat
584}
818}
internal/tui/tui.go+5−5
@@ -13,6 +13,7 @@ import (
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/status"
18 "github.com/charmbracelet/crush/internal/tui/components/core/status"
18 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -257,7 +258,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
257 var cmds []tea.Cmd
258 var cmds []tea.Cmd
258 a.wWidth, a.wHeight = width, height
259 a.wWidth, a.wHeight = width, height
259 if a.showingFullHelp {
260 if a.showingFullHelp {
260− height -= 4
261+ height -= 5
261 } else {
262 } else {
262 height -= 2
263 height -= 2
263 }
264 }
@@ -384,10 +385,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
384// View renders the complete application interface including pages, dialogs, and overlays.
385// View renders the complete application interface including pages, dialogs, and overlays.
385func (a *appModel) View() tea.View {
386func (a *appModel) View() tea.View {
386 page := a.pages[a.currentPage]
387 page := a.pages[a.currentPage]
387− if withHelp, ok := page.(layout.Help); ok {
388+ if withHelp, ok := page.(core.KeyMapHelp); ok {
388− a.keyMap.pageBindings = withHelp.Bindings()
389+ a.status.SetKeyMap(withHelp.Help())
389 }
390 }
390− a.status.SetKeyMap(a.keyMap)
391 pageView := page.View()
391 pageView := page.View()
392 components := []string{
392 components := []string{
393 pageView,
393 pageView,
@@ -447,7 +447,7 @@ func New(app *app.App) tea.Model {
447 model := &appModel{
447 model := &appModel{
448 currentPage: chat.ChatPageID,
448 currentPage: chat.ChatPageID,
449 app: app,
449 app: app,
450− status: status.NewStatusCmp(keyMap),
450+ status: status.NewStatusCmp(),
451 loadedPages: make(map[page.PageID]bool),
451 loadedPages: make(map[page.PageID]bool),
452 keyMap: keyMap,
452 keyMap: keyMap,
453
453