on main help sign in
chore: improve help
commit1f16850fba6465bcc336a1309eedc076ac493b3b authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 10 Jul 2025, 13:20 (1 year ago) parentc748153
13 files changed+301−728
internal/tui/components/chat/editor/editor.go−3 internal/tui/components/chat/editor/keys.go+1−8 internal/tui/components/chat/splash/splash.go+11−2 internal/tui/components/core/helpers.go → internal/tui/components/core/core.go+28 internal/tui/components/core/layout/container.go−263 internal/tui/components/core/layout/layout.go+2−5 internal/tui/components/core/layout/split.go−375 internal/tui/components/core/list/keys.go+10−1 internal/tui/components/core/list/list.go+4 internal/tui/components/core/status/status.go+1−2 internal/tui/keys.go+2−61 internal/tui/page/chat/chat.go+237−3 internal/tui/tui.go+5−5
internal/tui/components/chat/editor/editor.go−3
@@ -157,9 +157,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157157 var cmd tea.Cmd
158158 var cmds []tea.Cmd
159159 switch msg := msg.(type) {
160− case tea.KeyboardEnhancementsMsg:
161− m.keyMap.keyboard = msg
162− return m, nil
163160 case filepicker.FilePickedMsg:
164161 if len(m.attachments) >= maxAttachments {
165162 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
22
33import (
44 "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
65)
76
87type EditorKeyMap struct {
@@ -10,8 +9,6 @@ type EditorKeyMap struct {
109 SendMessage key.Binding
1110 OpenEditor key.Binding
1211 Newline key.Binding
13−
14− keyboard tea.KeyboardEnhancementsMsg
1512}
1613
1714func DefaultEditorKeyMap() EditorKeyMap {
@@ -40,15 +37,11 @@ func DefaultEditorKeyMap() EditorKeyMap {
4037
4138// KeyBindings implements layout.KeyMapProvider
4239func (k EditorKeyMap) KeyBindings() []key.Binding {
43− newline := k.Newline
44− if k.keyboard.SupportsKeyDisambiguation() {
45− newline.SetHelp("shift+enter", newline.Help().Desc)
46− }
4740 return []key.Binding{
4841 k.AddFile,
4942 k.SendMessage,
5043 k.OpenEditor,
51− newline,
44+ k.Newline,
5245 }
5346}
5447
internal/tui/components/chat/splash/splash.go+11−2
@@ -32,6 +32,9 @@ type Splash interface {
3232 SetOnboarding(bool)
3333 // SetProjectInit controls whether the splash shows project initialization prompt
3434 SetProjectInit(bool)
35+
36+ // Showing API key input
37+ IsShowingAPIKey() bool
3538}
3639
3740const (
@@ -126,9 +129,11 @@ func (s *splashCmp) Init() tea.Cmd {
126129
127130// SetSize implements SplashPage.
128131func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
129− s.width = width
130132 s.height = height
131− s.logoRendered = s.logoBlock()
133+ if width != s.width {
134+ s.width = width
135+ s.logoRendered = s.logoBlock()
136+ }
132137 listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title
133138 listWidth := min(60, width-(SplashScreenPaddingX*2))
134139
@@ -626,3 +631,7 @@ func (s *splashCmp) mcpBlock() string {
626631 ),
627632 )
628633}
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 (
55 "strings"
66
77 "github.com/alecthomas/chroma/v2"
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
810 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
911 "github.com/charmbracelet/crush/internal/tui/styles"
1012 "github.com/charmbracelet/lipgloss/v2"
1113 "github.com/charmbracelet/x/ansi"
1214)
1315
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+
1442func Section(text string, width int) string {
1543 t := styles.CurrentTheme()
1644 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 (
55 tea "github.com/charmbracelet/bubbletea/v2"
66)
77
8+// TODO: move this to core
9+
810type Focusable interface {
911 Focus() tea.Cmd
1012 Blur() tea.Cmd
@@ -23,8 +25,3 @@ type Help interface {
2325type Positional interface {
2426 SetPosition(x, y int) tea.Cmd
2527}
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 {
99 Up,
1010 DownOneItem,
1111 UpOneItem,
12+ PageDown,
13+ PageUp,
1214 HalfPageDown,
1315 HalfPageUp,
1416 Home,
@@ -37,7 +39,14 @@ func DefaultKeyMap() KeyMap {
3739 key.WithKeys("d"),
3840 key.WithHelp("d", "half page down"),
3941 ),
40− HalfPageUp: key.NewBinding(
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"),
49+ ), HalfPageUp: key.NewBinding(
4150 key.WithKeys("u"),
4251 key.WithHelp("u", "half page up"),
4352 ),
internal/tui/components/core/list/list.go+4
@@ -332,6 +332,10 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
332332 m.scrollDown(m.listHeight() / 2)
333333 case key.Matches(msg, m.keyMap.HalfPageUp):
334334 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())
335339 case key.Matches(msg, m.keyMap.Home):
336340 return m, m.goToTop()
337341 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) {
9898 m.keyMap = keyMap
9999}
100100
101−func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
101+func NewStatusCmp() StatusCmp {
102102 t := styles.CurrentTheme()
103103 help := help.New()
104104 help.Styles = t.S().Help
105105 return &statusCmp{
106106 messageTTL: 5 * time.Second,
107107 help: help,
108− keyMap: keyMap,
109108 }
110109}
internal/tui/keys.go+2−61
@@ -20,8 +20,8 @@ func DefaultKeyMap() KeyMap {
2020 key.WithHelp("ctrl+c", "quit"),
2121 ),
2222 Help: key.NewBinding(
23− key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
24− key.WithHelp("ctrl+?", "more"),
23+ key.WithKeys("ctrl+g"),
24+ key.WithHelp("ctrl+g", "more"),
2525 ),
2626 Commands: key.NewBinding(
2727 key.WithKeys("ctrl+p"),
@@ -33,62 +33,3 @@ func DefaultKeyMap() KeyMap {
3333 ),
3434 }
3535}
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
22
33import (
44 "context"
5+ "runtime"
56 "time"
67
8+ "github.com/charmbracelet/bubbles/v2/help"
79 "github.com/charmbracelet/bubbles/v2/key"
810 "github.com/charmbracelet/bubbles/v2/spinner"
911 tea "github.com/charmbracelet/bubbletea/v2"
@@ -20,6 +22,7 @@ import (
2022 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
2123 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
2224 "github.com/charmbracelet/crush/internal/tui/components/completions"
25+ "github.com/charmbracelet/crush/internal/tui/components/core"
2326 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
2427 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
2528 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
@@ -82,6 +85,7 @@ type chatPage struct {
8285 width, height int
8386 detailsWidth, detailsHeight int
8487 app *app.App
88+ keyboardEnhancements tea.KeyboardEnhancementsMsg
8589
8690 // Layout state
8791 compact bool
@@ -103,6 +107,8 @@ type chatPage struct {
103107 showingDetails bool
104108 isCanceling bool
105109 splashFullScreen bool
110+ isOnboarding bool
111+ isProjectInit bool
106112}
107113
108114func New(app *app.App) ChatPage {
@@ -129,10 +135,12 @@ func (p *chatPage) Init() tea.Cmd {
129135 if !config.HasInitialDataConfig() {
130136 // First-time setup: show model selection
131137 p.splash.SetOnboarding(true)
138+ p.isOnboarding = true
132139 p.splashFullScreen = true
133140 } else if b, _ := config.ProjectNeedsInitialization(); b {
134141 // Project needs CRUSH.md initialization
135142 p.splash.SetProjectInit(true)
143+ p.isProjectInit = true
136144 p.splashFullScreen = true
137145 } else {
138146 // Ready to chat: focus editor, splash in background
@@ -153,9 +161,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153161 var cmds []tea.Cmd
154162 switch msg := msg.(type) {
155163 case tea.KeyboardEnhancementsMsg:
156− m, cmd := p.editor.Update(msg)
157− p.editor = m.(editor.Editor)
158− return p, cmd
164+ p.keyboardEnhancements = msg
165+ return p, nil
159166 case tea.WindowSizeMsg:
160167 return p, p.SetSize(msg.Width, msg.Height)
161168 case CancelTimerExpiredMsg:
@@ -237,6 +244,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237244 if err != nil {
238245 return p, util.ReportError(err)
239246 }
247+ p.isOnboarding = false
248+ p.isProjectInit = false
240249 p.focusedPane = PanelTypeEditor
241250 return p, p.SetSize(p.width, p.height)
242251 case tea.KeyPressMsg:
@@ -579,6 +588,231 @@ func (p *chatPage) Bindings() []key.Binding {
579588 return bindings
580589}
581590
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+
582816func (p *chatPage) IsChatFocused() bool {
583817 return p.focusedPane == PanelTypeChat
584818}
internal/tui/tui.go+5−5
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/pubsub"
1414 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
1515 "github.com/charmbracelet/crush/internal/tui/components/completions"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
1617 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1718 "github.com/charmbracelet/crush/internal/tui/components/core/status"
1819 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -257,7 +258,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
257258 var cmds []tea.Cmd
258259 a.wWidth, a.wHeight = width, height
259260 if a.showingFullHelp {
260− height -= 4
261+ height -= 5
261262 } else {
262263 height -= 2
263264 }
@@ -384,10 +385,9 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
384385// View renders the complete application interface including pages, dialogs, and overlays.
385386func (a *appModel) View() tea.View {
386387 page := a.pages[a.currentPage]
387− if withHelp, ok := page.(layout.Help); ok {
388− a.keyMap.pageBindings = withHelp.Bindings()
388+ if withHelp, ok := page.(core.KeyMapHelp); ok {
389+ a.status.SetKeyMap(withHelp.Help())
389390 }
390− a.status.SetKeyMap(a.keyMap)
391391 pageView := page.View()
392392 components := []string{
393393 pageView,
@@ -447,7 +447,7 @@ func New(app *app.App) tea.Model {
447447 model := &appModel{
448448 currentPage: chat.ChatPageID,
449449 app: app,
450− status: status.NewStatusCmp(keyMap),
450+ status: status.NewStatusCmp(),
451451 loadedPages: make(map[page.PageID]bool),
452452 keyMap: keyMap,
453453