on main help sign in
Merge origin/main into drop-reflection-and-use-string-builder
4f864e0Raphael Amorim
internal/llm/provider/openai.go+1−1
@@ -166,7 +166,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
166 Tools: tools,
166 Tools: tools,
167 }
167 }
168
168
169− if o.providerOptions.model.CanReason == true {
169+ if o.providerOptions.model.CanReason {
170 params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
170 params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
171 switch o.options.reasoningEffort {
171 switch o.options.reasoningEffort {
172 case "low":
172 case "low":
internal/llm/tools/shell/comparison_test.gonew file+83
@@ -0,0 +1,83 @@
1+package shell
2+
3+import (
4+ "context"
5+ "os"
6+ "runtime"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestShellPerformanceComparison(t *testing.T) {
15+ tmpDir, err := os.MkdirTemp("", "shell-test")
16+ require.NoError(t, err)
17+ defer os.RemoveAll(tmpDir)
18+
19+ shell := GetPersistentShell(tmpDir)
20+ defer shell.Close()
21+
22+ // Test quick command
23+ start := time.Now()
24+ stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello'", 0)
25+ duration := time.Since(start)
26+
27+ require.NoError(t, err)
28+ assert.Equal(t, 0, exitCode)
29+ assert.Contains(t, stdout, "hello")
30+ assert.Empty(t, stderr)
31+
32+ t.Logf("Quick command took: %v", duration)
33+}
34+
35+func TestShellCPUUsageComparison(t *testing.T) {
36+ tmpDir, err := os.MkdirTemp("", "shell-test")
37+ require.NoError(t, err)
38+ defer os.RemoveAll(tmpDir)
39+
40+ shell := GetPersistentShell(tmpDir)
41+ defer shell.Close()
42+
43+ // Measure CPU and memory usage during a longer command
44+ var m1, m2 runtime.MemStats
45+ runtime.GC()
46+ runtime.ReadMemStats(&m1)
47+
48+ start := time.Now()
49+ _, stderr, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.1", 1000)
50+ duration := time.Since(start)
51+
52+ runtime.ReadMemStats(&m2)
53+
54+ require.NoError(t, err)
55+ assert.Equal(t, 0, exitCode)
56+ assert.Empty(t, stderr)
57+
58+ memGrowth := m2.Alloc - m1.Alloc
59+ t.Logf("Sleep 0.1s command took: %v", duration)
60+ t.Logf("Memory growth during polling: %d bytes", memGrowth)
61+ t.Logf("GC cycles during test: %d", m2.NumGC-m1.NumGC)
62+}
63+
64+// Benchmark CPU usage during polling
65+func BenchmarkShellPolling(b *testing.B) {
66+ tmpDir, err := os.MkdirTemp("", "shell-bench")
67+ require.NoError(b, err)
68+ defer os.RemoveAll(tmpDir)
69+
70+ shell := GetPersistentShell(tmpDir)
71+ defer shell.Close()
72+
73+ b.ResetTimer()
74+ b.ReportAllocs()
75+
76+ for i := 0; i < b.N; i++ {
77+ // Use a short sleep to measure polling overhead
78+ _, _, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.02", 500)
79+ if err != nil || exitCode != 0 {
80+ b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
81+ }
82+ }
83+}
internal/llm/tools/shell/shell.go+17−1
@@ -189,6 +189,13 @@ echo $EXEC_EXIT_CODE > %s
189
189
190 done := make(chan bool)
190 done := make(chan bool)
191 go func() {
191 go func() {
192+ // Use exponential backoff polling
193+ pollInterval := 1 * time.Millisecond
194+ maxPollInterval := 100 * time.Millisecond
195+
196+ ticker := time.NewTicker(pollInterval)
197+ defer ticker.Stop()
198+
192 for {
199 for {
193 select {
200 select {
194 case <-ctx.Done():
201 case <-ctx.Done():
@@ -197,7 +204,7 @@ echo $EXEC_EXIT_CODE > %s
197 done <- true
204 done <- true
198 return
205 return
199
206
200− case <-time.After(10 * time.Millisecond):
207+ case <-ticker.C:
201 if fileExists(statusFile) && fileSize(statusFile) > 0 {
208 if fileExists(statusFile) && fileSize(statusFile) > 0 {
202 done <- true
209 done <- true
203 return
210 return
@@ -212,6 +219,15 @@ echo $EXEC_EXIT_CODE > %s
212 return
219 return
213 }
220 }
214 }
221 }
222+
223+ // Exponential backoff to reduce CPU usage for longer-running commands
224+ if pollInterval < maxPollInterval {
225+ pollInterval = time.Duration(float64(pollInterval) * 1.5)
226+ if pollInterval > maxPollInterval {
227+ pollInterval = maxPollInterval
228+ }
229+ ticker.Reset(pollInterval)
230+ }
215 }
231 }
216 }
232 }
217 }()
233 }()
internal/llm/tools/shell/shell_test.gonew file+54
@@ -0,0 +1,54 @@
1+package shell
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestShellPerformanceImprovement(t *testing.T) {
14+ // Create a temporary directory for the shell
15+ tmpDir, err := os.MkdirTemp("", "shell-test")
16+ require.NoError(t, err)
17+ defer os.RemoveAll(tmpDir)
18+
19+ shell := GetPersistentShell(tmpDir)
20+ defer shell.Close()
21+
22+ // Test that quick commands complete fast
23+ start := time.Now()
24+ stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello world'", 0)
25+ duration := time.Since(start)
26+
27+ require.NoError(t, err)
28+ assert.Equal(t, 0, exitCode)
29+ assert.Contains(t, stdout, "hello world")
30+ assert.Empty(t, stderr)
31+
32+ // Quick commands should complete very fast with our exponential backoff
33+ assert.Less(t, duration, 50*time.Millisecond, "Quick command should complete fast with exponential backoff")
34+}
35+
36+// Benchmark to measure CPU efficiency
37+func BenchmarkShellQuickCommands(b *testing.B) {
38+ tmpDir, err := os.MkdirTemp("", "shell-bench")
39+ require.NoError(b, err)
40+ defer os.RemoveAll(tmpDir)
41+
42+ shell := GetPersistentShell(tmpDir)
43+ defer shell.Close()
44+
45+ b.ResetTimer()
46+ b.ReportAllocs()
47+
48+ for i := 0; i < b.N; i++ {
49+ _, _, exitCode, _, err := shell.Exec(context.Background(), "echo test", 0)
50+ if err != nil || exitCode != 0 {
51+ b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
52+ }
53+ }
54+}
internal/tui/components/chat/chat.go+13−5
@@ -4,6 +4,7 @@ import (
4 "context"
4 "context"
5 "time"
5 "time"
6
6
7+ "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/llm/agent"
10 "github.com/charmbracelet/crush/internal/llm/agent"
@@ -49,21 +50,23 @@ type messageListCmp struct {
49 previousSelected int // Last selected item index for restoring focus
50 previousSelected int // Last selected item index for restoring focus
50
51
51 lastUserMessageTime int64
52 lastUserMessageTime int64
53+ defaultListKeyMap list.KeyMap
52}
54}
53
55
54// NewMessagesListCmp creates a new message list component with custom keybindings
56// NewMessagesListCmp creates a new message list component with custom keybindings
55// and reverse ordering (newest messages at bottom).
57// and reverse ordering (newest messages at bottom).
56func NewMessagesListCmp(app *app.App) MessageListCmp {
58func NewMessagesListCmp(app *app.App) MessageListCmp {
57− defaultKeymaps := list.DefaultKeyMap()
59+ defaultListKeyMap := list.DefaultKeyMap()
58 listCmp := list.New(
60 listCmp := list.New(
59 list.WithGapSize(1),
61 list.WithGapSize(1),
60 list.WithReverse(true),
62 list.WithReverse(true),
61− list.WithKeyMap(defaultKeymaps),
63+ list.WithKeyMap(defaultListKeyMap),
62 )
64 )
63 return &messageListCmp{
65 return &messageListCmp{
64− app: app,
66+ app: app,
65− listCmp: listCmp,
67+ listCmp: listCmp,
66− previousSelected: list.NoSelection,
68+ previousSelected: list.NoSelection,
69+ defaultListKeyMap: defaultListKeyMap,
67 }
70 }
68}
71}
69
72
@@ -495,3 +498,8 @@ func (m *messageListCmp) Focus() tea.Cmd {
495func (m *messageListCmp) IsFocused() bool {
498func (m *messageListCmp) IsFocused() bool {
496 return m.listCmp.IsFocused()
499 return m.listCmp.IsFocused()
497}
500}
501+
502+func (m *messageListCmp) Bindings() []key.Binding {
503+ bindings := layout.KeyMapToSlice(m.defaultListKeyMap)
504+ return bindings
505+}
internal/tui/components/chat/editor/editor.go+6−1
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -248,7 +249,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
248 return m, nil
249 return m, nil
249 }
250 }
250 // Hanlde Enter key
251 // Hanlde Enter key
251− if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
252+ if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
252 value := m.textarea.Value()
253 value := m.textarea.Value()
253 if len(value) > 0 && value[len(value)-1] == '\\' {
254 if len(value) > 0 && value[len(value)-1] == '\\' {
254 // If the last character is a backslash, remove it and add a newline
255 // If the last character is a backslash, remove it and add a newline
@@ -370,6 +371,10 @@ func (c *editorCmp) IsFocused() bool {
370 return c.textarea.Focused()
371 return c.textarea.Focused()
371}
372}
372
373
374+func (c *editorCmp) Bindings() []key.Binding {
375+ return layout.KeyMapToSlice(c.keyMap)
376+}
377+
373func NewEditorCmp(app *app.App) util.Model {
378func NewEditorCmp(app *app.App) util.Model {
374 t := styles.CurrentTheme()
379 t := styles.CurrentTheme()
375 ta := textarea.New()
380 ta := textarea.New()
internal/tui/components/chat/editor/keys.go+13−29
@@ -5,13 +5,18 @@ import (
5)
5)
6
6
7type EditorKeyMap struct {
7type EditorKeyMap struct {
8− Send key.Binding
8+ AddFile key.Binding
9− OpenEditor key.Binding
9+ SendMessage key.Binding
10+ OpenEditor key.Binding
10}
11}
11
12
12func DefaultEditorKeyMap() EditorKeyMap {
13func DefaultEditorKeyMap() EditorKeyMap {
13 return EditorKeyMap{
14 return EditorKeyMap{
15+ AddFile: key.NewBinding(
16+ key.WithKeys("/"),
17+ key.WithHelp("/", "add file"),
18+ ),
14− Send: key.NewBinding(
19+ SendMessage: key.NewBinding(
15 key.WithKeys("enter"),
20 key.WithKeys("enter"),
16 key.WithHelp("enter", "send"),
21 key.WithHelp("enter", "send"),
17 ),
22 ),
@@ -22,31 +27,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
22 }
27 }
23}
28}
24
29
25−// KeyBindings implements layout.KeyMapProvider
30+type DeleteAttachmentKeyMaps struct {
26−func (k EditorKeyMap) KeyBindings() []key.Binding {
27− return []key.Binding{
28− k.Send,
29− k.OpenEditor,
30− }
31−}
32−
33−// FullHelp implements help.KeyMap.
34−func (k EditorKeyMap) FullHelp() [][]key.Binding {
35− m := [][]key.Binding{}
31+ AttachmentDeleteMode key.Binding
36− slice := k.KeyBindings()
32+ Escape key.Binding
37− for i := 0; i < len(slice); i += 4 {
38− end := min(i+4, len(slice))
39− m = append(m, slice[i:end])
40− }
41− return m
42−}
43−
44−// ShortHelp implements help.KeyMap.
45−func (k EditorKeyMap) ShortHelp() []key.Binding {
46− return []key.Binding{
33+ DeleteAllAttachments key.Binding
47− k.Send,
48− k.OpenEditor,
49− }
50}
34}
51
35
52// TODO: update this to use the new keymap concepts
36// TODO: update this to use the new keymap concepts
@@ -63,4 +47,4 @@ var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
63 key.WithKeys("r"),
47 key.WithKeys("r"),
64 key.WithHelp("ctrl+r+r", "delete all attachments"),
48 key.WithHelp("ctrl+r+r", "delete all attachments"),
65 ),
49 ),
66−}
50+}
internal/tui/components/core/layout/container.go+3−3
@@ -1,7 +1,7 @@
1package layout
1package layout
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/v2/help"
4+ "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/tui/styles"
6 "github.com/charmbracelet/crush/internal/tui/styles"
7 "github.com/charmbracelet/crush/internal/tui/util"
7 "github.com/charmbracelet/crush/internal/tui/util"
@@ -157,9 +157,9 @@ func (c *container) SetPosition(x, y int) tea.Cmd {
157 return nil
157 return nil
158}
158}
159
159
160−func (c *container) Help() help.KeyMap {
160+func (c *container) Bindings() []key.Binding {
161 if b, ok := c.content.(Help); ok {
161 if b, ok := c.content.(Help); ok {
162− return b.Help()
162+ return b.Bindings()
163 }
163 }
164 return nil
164 return nil
165}
165}
internal/tui/components/core/layout/layout.go+4−3
@@ -1,7 +1,8 @@
1package layout
1package layout
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/v2/help"
4+ "reflect"
5+
5 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
7)
8)
@@ -18,7 +19,7 @@ type Sizeable interface {
18}
19}
19
20
20type Help interface {
21type Help interface {
21− Help() help.KeyMap
22+ Bindings() []key.Binding
22}
23}
23
24
24type Positionable interface {
25type Positionable interface {
@@ -28,4 +29,4 @@ type Positionable interface {
28// KeyMapProvider defines an interface for types that can provide their key bindings as a slice
29// KeyMapProvider defines an interface for types that can provide their key bindings as a slice
29type KeyMapProvider interface {
30type KeyMapProvider interface {
30 KeyBindings() []key.Binding
31 KeyBindings() []key.Binding
31−}
32+}
internal/tui/components/core/layout/split.go+5−5
@@ -1,7 +1,7 @@
1package layout
1package layout
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/v2/help"
4+ "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/tui/styles"
6 "github.com/charmbracelet/crush/internal/tui/styles"
7 "github.com/charmbracelet/crush/internal/tui/util"
7 "github.com/charmbracelet/crush/internal/tui/util"
@@ -269,20 +269,20 @@ func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
269 return nil
269 return nil
270}
270}
271
271
272−func (s *splitPaneLayout) Help() help.KeyMap {
272+func (s *splitPaneLayout) Bindings() []key.Binding {
273 if s.leftPanel != nil {
273 if s.leftPanel != nil {
274 if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
274 if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
275− return b.Help()
275+ return b.Bindings()
276 }
276 }
277 }
277 }
278 if s.rightPanel != nil {
278 if s.rightPanel != nil {
279 if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
279 if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
280− return b.Help()
280+ return b.Bindings()
281 }
281 }
282 }
282 }
283 if s.bottomPanel != nil {
283 if s.bottomPanel != nil {
284 if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
284 if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
285− return b.Help()
285+ return b.Bindings()
286 }
286 }
287 }
287 }
288 return nil
288 return nil
internal/tui/components/core/list/keys.go+1−36
@@ -50,39 +50,4 @@ func DefaultKeyMap() KeyMap {
50 key.WithKeys("shift+g", "end"),
50 key.WithKeys("shift+g", "end"),
51 ),
51 ),
52 }
52 }
53−}
53+}
54−
55−// KeyBindings implements layout.KeyMapProvider
56−func (k KeyMap) KeyBindings() []key.Binding {
57− return []key.Binding{
58− k.Down,
59− k.Up,
60− k.NDown,
61− k.NUp,
62− k.DownOneItem,
63− k.UpOneItem,
64− k.HalfPageDown,
65− k.HalfPageUp,
66− k.Home,
67− k.End,
68− }
69−}
70−
71−// FullHelp implements help.KeyMap.
72−func (k KeyMap) FullHelp() [][]key.Binding {
73− m := [][]key.Binding{}
74− slice := k.KeyBindings()
75− for i := 0; i < len(slice); i += 4 {
76− end := min(i+4, len(slice))
77− m = append(m, slice[i:end])
78− }
79− return m
80−}
81−
82−// ShortHelp implements help.KeyMap.
83−func (k KeyMap) ShortHelp() []key.Binding {
84− return []key.Binding{
85− k.Up,
86− k.Down,
87− }
88−}
internal/tui/components/core/status/keys.godeleted−64
@@ -1,64 +0,0 @@
1−package status
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Tab,
9− Commands,
10− Sessions,
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− Sessions: key.NewBinding(
25− key.WithKeys("ctrl+s"),
26− key.WithHelp("ctrl+s", "sessions"),
27− ),
28− Help: key.NewBinding(
29− key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
30− key.WithHelp("ctrl+?", "more"),
31− ),
32− }
33−}
34−
35−// KeyBindings implements layout.KeyMapProvider
36−func (k KeyMap) KeyBindings() []key.Binding {
37− return []key.Binding{
38− k.Tab,
39− k.Commands,
40− k.Sessions,
41− k.Help,
42− }
43−}
44−
45−// FullHelp implements help.KeyMap.
46−func (k KeyMap) FullHelp() [][]key.Binding {
47− m := [][]key.Binding{}
48− slice := k.KeyBindings()
49− for i := 0; i < len(slice); i += 4 {
50− end := min(i+4, len(slice))
51− m = append(m, slice[i:end])
52− }
53− return m
54−}
55−
56−// ShortHelp implements help.KeyMap.
57−func (k KeyMap) ShortHelp() []key.Binding {
58− return []key.Binding{
59− k.Tab,
60− k.Commands,
61− k.Sessions,
62− k.Help,
63− }
64−}
internal/tui/components/core/status/status.go+19−6
@@ -14,6 +14,8 @@ import (
14
14
15type StatusCmp interface {
15type StatusCmp interface {
16 util.Model
16 util.Model
17+ ToggleFullHelp()
18+ SetKeyMap(keyMap help.KeyMap)
17}
19}
18
20
19type statusCmp struct {
21type statusCmp struct {
@@ -22,23 +24,25 @@ type statusCmp struct {
22 messageTTL time.Duration
24 messageTTL time.Duration
23 session session.Session
25 session session.Session
24 help help.Model
26 help help.Model
27+ keyMap help.KeyMap
25}
28}
26
29
27// clearMessageCmd is a command that clears status messages after a timeout
30// clearMessageCmd is a command that clears status messages after a timeout
28−func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
31+func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
29 return tea.Tick(ttl, func(time.Time) tea.Msg {
32 return tea.Tick(ttl, func(time.Time) tea.Msg {
30 return util.ClearStatusMsg{}
33 return util.ClearStatusMsg{}
31 })
34 })
32}
35}
33
36
34−func (m statusCmp) Init() tea.Cmd {
37+func (m *statusCmp) Init() tea.Cmd {
35 return nil
38 return nil
36}
39}
37
40
38−func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41+func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39 switch msg := msg.(type) {
42 switch msg := msg.(type) {
40 case tea.WindowSizeMsg:
43 case tea.WindowSizeMsg:
41 m.width = msg.Width
44 m.width = msg.Width
45+ m.help.Width = msg.Width - 2
42 return m, nil
46 return m, nil
43
47
44 // Handle status info
48 // Handle status info
@@ -86,9 +90,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 return m, nil
90 return m, nil
87}
91}
88
92
89−func (m statusCmp) View() tea.View {
93+func (m *statusCmp) View() tea.View {
90 t := styles.CurrentTheme()
94 t := styles.CurrentTheme()
91− status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
95+ status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
92 if m.info.Msg != "" {
96 if m.info.Msg != "" {
93 switch m.info.Type {
97 switch m.info.Type {
94 case util.InfoTypeError:
98 case util.InfoTypeError:
@@ -102,12 +106,21 @@ func (m statusCmp) View() tea.View {
102 return tea.NewView(status)
106 return tea.NewView(status)
103}
107}
104
108
109+func (m *statusCmp) ToggleFullHelp() {
110+ m.help.ShowAll = !m.help.ShowAll
111+}
112+
113+func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
114+ m.keyMap = keyMap
115+}
116+
105−func NewStatusCmp() StatusCmp {
117+func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
106 t := styles.CurrentTheme()
118 t := styles.CurrentTheme()
107 help := help.New()
119 help := help.New()
108 help.Styles = t.S().Help
120 help.Styles = t.S().Help
109 return &statusCmp{
121 return &statusCmp{
110 messageTTL: 10 * time.Second,
122 messageTTL: 10 * time.Second,
111 help: help,
123 help: help,
124+ keyMap: keyMap,
112 }
125 }
113}
126}
internal/tui/keys.go+51−17
@@ -10,6 +10,8 @@ type KeyMap struct {
10 Help key.Binding
10 Help key.Binding
11 Commands key.Binding
11 Commands key.Binding
12 Sessions key.Binding
12 Sessions key.Binding
13+
14+ pageBindings []key.Binding
13}
15}
14
16
15func DefaultKeyMap() KeyMap {
17func DefaultKeyMap() KeyMap {
@@ -22,10 +24,9 @@ func DefaultKeyMap() KeyMap {
22 key.WithKeys("ctrl+c"),
24 key.WithKeys("ctrl+c"),
23 key.WithHelp("ctrl+c", "quit"),
25 key.WithHelp("ctrl+c", "quit"),
24 ),
26 ),
25−
26 Help: key.NewBinding(
27 Help: key.NewBinding(
27− key.WithKeys("ctrl+_"),
28+ key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
28− key.WithHelp("ctrl+?", "toggle help"),
29+ key.WithHelp("ctrl+?", "more"),
29 ),
30 ),
30 Commands: key.NewBinding(
31 Commands: key.NewBinding(
31 key.WithKeys("ctrl+p"),
32 key.WithKeys("ctrl+p"),
@@ -41,26 +42,59 @@ func DefaultKeyMap() KeyMap {
41// FullHelp implements help.KeyMap.
42// FullHelp implements help.KeyMap.
42func (k KeyMap) FullHelp() [][]key.Binding {
43func (k KeyMap) FullHelp() [][]key.Binding {
43 m := [][]key.Binding{}
44 m := [][]key.Binding{}
44− slice := k.KeyBindings()
45+ slice := []key.Binding{
46+ k.Commands,
47+ k.Sessions,
48+ k.Quit,
49+ k.Help,
50+ k.Logs,
51+ }
52+ slice = k.prependEscAndTab(slice)
53+ slice = append(slice, k.pageBindings...)
54+ // remove duplicates
55+ seen := make(map[string]bool)
56+ cleaned := []key.Binding{}
57+ for _, b := range slice {
58+ if !seen[b.Help().Key] {
59+ seen[b.Help().Key] = true
60+ cleaned = append(cleaned, b)
61+ }
62+ }
63+
45− for i := 0; i < len(slice); i += 4 {
64+ for i := 0; i < len(cleaned); i += 2 {
46− end := min(i+4, len(slice))
65+ end := min(i+2, len(cleaned))
47− m = append(m, slice[i:end])
66+ m = append(m, cleaned[i:end])
48 }
67 }
49 return m
68 return m
50}
69}
51
70
52−// KeyBindings implements layout.KeyMapProvider
53−func (k KeyMap) KeyBindings() []key.Binding {
71+func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding {
54− return []key.Binding{
72+ var cancel key.Binding
55− k.Logs,
73+ var tab key.Binding
56− k.Quit,
74+ for _, b := range k.pageBindings {
57− k.Help,
75+ if b.Help().Key == "esc" {
58− k.Commands,
76+ cancel = b
59− k.Sessions,
77+ }
78+ if b.Help().Key == "tab" {
79+ tab = b
80+ }
60 }
81 }
82+ if tab.Help().Key != "" {
83+ bindings = append([]key.Binding{tab}, bindings...)
84+ }
85+ if cancel.Help().Key != "" {
86+ bindings = append([]key.Binding{cancel}, bindings...)
87+ }
88+ return bindings
61}
89}
62
90
63// ShortHelp implements help.KeyMap.
91// ShortHelp implements help.KeyMap.
64func (k KeyMap) ShortHelp() []key.Binding {
92func (k KeyMap) ShortHelp() []key.Binding {
65− return []key.Binding{}
93+ bindings := []key.Binding{
94+ k.Commands,
95+ k.Sessions,
96+ k.Quit,
97+ k.Help,
98+ }
99+ return k.prependEscAndTab(bindings)
66−}
100+}
internal/tui/page/chat/chat.go+49−15
@@ -19,26 +19,31 @@ import (
19 "github.com/charmbracelet/crush/internal/tui/util"
19 "github.com/charmbracelet/crush/internal/tui/util"
20)
20)
21
21
22−var ChatPage page.PageID = "chat"
22+var ChatPageID page.PageID = "chat"
23−
24−type ChatFocusedMsg struct {
25− Focused bool // True if the chat input is focused, false otherwise
26−}
27
23
28type (
24type (
29 OpenFilePickerMsg struct{}
25 OpenFilePickerMsg struct{}
30− chatPage struct {
26+ ChatFocusedMsg struct {
31− app *app.App
27+ Focused bool // True if the chat input is focused, false otherwise
28+ }
29+)
32
30
33− layout layout.SplitPaneLayout
31+type ChatPage interface {
32+ util.Model
33+ layout.Help
34+}
34
35
35− session session.Session
36+type chatPage struct {
37+ app *app.App
36
38
37− keyMap KeyMap
39+ layout layout.SplitPaneLayout
38
40
41+ session session.Session
42+
43+ keyMap KeyMap
44+
39− chatFocused bool
45+ chatFocused bool
40− }
46+}
41−)
42
47
43func (p *chatPage) Init() tea.Cmd {
48func (p *chatPage) Init() tea.Cmd {
44 cmd := p.layout.Init()
49 cmd := p.layout.Init()
@@ -87,7 +92,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 util.CmdHandler(chat.SessionClearedMsg{}),
92 util.CmdHandler(chat.SessionClearedMsg{}),
88 )
93 )
89
94
90− case key.Matches(msg, p.keyMap.FilePicker):
95+ case key.Matches(msg, p.keyMap.AddAttachment):
91 cfg := config.Get()
96 cfg := config.Get()
92 agentCfg := cfg.Agents[config.AgentCoder]
97 agentCfg := cfg.Agents[config.AgentCoder]
93 selectedModelID := agentCfg.Model
98 selectedModelID := agentCfg.Model
@@ -173,7 +178,36 @@ func (p *chatPage) View() tea.View {
173 return p.layout.View()
178 return p.layout.View()
174}
179}
175
180
181+func (p *chatPage) Bindings() []key.Binding {
182+ bindings := []key.Binding{
183+ p.keyMap.NewSession,
184+ p.keyMap.AddAttachment,
185+ }
186+ if p.app.CoderAgent.IsBusy() {
187+ bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
188+ }
189+
190+ if p.chatFocused {
191+ bindings = append([]key.Binding{
192+ key.NewBinding(
193+ key.WithKeys("tab"),
194+ key.WithHelp("tab", "focus editor"),
195+ ),
196+ }, bindings...)
197+ } else {
198+ bindings = append([]key.Binding{
199+ key.NewBinding(
200+ key.WithKeys("tab"),
201+ key.WithHelp("tab", "focus chat"),
202+ ),
203+ }, bindings...)
204+ }
205+
206+ bindings = append(bindings, p.layout.Bindings()...)
207+ return bindings
208+}
209+
176−func NewChatPage(app *app.App) util.Model {
210+func NewChatPage(app *app.App) ChatPage {
177 sidebarContainer := layout.NewContainer(
211 sidebarContainer := layout.NewContainer(
178 sidebar.NewSidebarCmp(),
212 sidebar.NewSidebarCmp(),
179 layout.WithPadding(1, 1, 1, 1),
213 layout.WithPadding(1, 1, 1, 1),
internal/tui/page/chat/keys.go+9−37
@@ -5,10 +5,10 @@ import (
5)
5)
6
6
7type KeyMap struct {
7type KeyMap struct {
8− NewSession key.Binding
8+ NewSession key.Binding
9− FilePicker key.Binding
9+ AddAttachment key.Binding
10− Cancel key.Binding
10+ Cancel key.Binding
11− Tab key.Binding
11+ Tab key.Binding
12}
12}
13
13
14func DefaultKeyMap() KeyMap {
14func DefaultKeyMap() KeyMap {
@@ -17,6 +17,10 @@ func DefaultKeyMap() KeyMap {
17 key.WithKeys("ctrl+n"),
17 key.WithKeys("ctrl+n"),
18 key.WithHelp("ctrl+n", "new session"),
18 key.WithHelp("ctrl+n", "new session"),
19 ),
19 ),
20+ AddAttachment: key.NewBinding(
21+ key.WithKeys("ctrl+f"),
22+ key.WithHelp("ctrl+f", "add attachment"),
23+ ),
20 Cancel: key.NewBinding(
24 Cancel: key.NewBinding(
21 key.WithKeys("esc"),
25 key.WithKeys("esc"),
22 key.WithHelp("esc", "cancel"),
26 key.WithHelp("esc", "cancel"),
@@ -25,37 +29,5 @@ func DefaultKeyMap() KeyMap {
25 key.WithKeys("tab"),
29 key.WithKeys("tab"),
26 key.WithHelp("tab", "change focus"),
30 key.WithHelp("tab", "change focus"),
27 ),
31 ),
28− FilePicker: key.NewBinding(
29− key.WithKeys("ctrl+f"),
30− key.WithHelp("ctrl+f", "select files to upload"),
31− ),
32− }
33−}
34−
35−// KeyBindings implements layout.KeyMapProvider
36−func (k KeyMap) KeyBindings() []key.Binding {
37− return []key.Binding{
38− k.NewSession,
39− k.FilePicker,
40− k.Cancel,
41− k.Tab,
42 }
32 }
43−}
33+}
44−
45−// FullHelp implements help.KeyMap.
46−func (k KeyMap) FullHelp() [][]key.Binding {
47− m := [][]key.Binding{}
48− slice := k.KeyBindings()
49− for i := 0; i < len(slice); i += 4 {
50− end := min(i+4, len(slice))
51− m = append(m, slice[i:end])
52− }
53− return m
54−}
55−
56−// ShortHelp implements help.KeyMap.
57−func (k KeyMap) ShortHelp() []key.Binding {
58− return []key.Binding{
59− k.Tab,
60− }
61−}
internal/tui/page/logs/logs.go+1−1
@@ -37,7 +37,7 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 case tea.KeyMsg:
37 case tea.KeyMsg:
38 switch {
38 switch {
39 case key.Matches(msg, p.keyMap.Back):
39 case key.Matches(msg, p.keyMap.Back):
40− return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPage})
40+ return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPageID})
41 }
41 }
42 }
42 }
43
43
internal/tui/tui.go+44−23
@@ -34,24 +34,26 @@ import (
34
34
35// appModel represents the main application model that manages pages, dialogs, and UI state.
35// appModel represents the main application model that manages pages, dialogs, and UI state.
36type appModel struct {
36type appModel struct {
37+ wWidth, wHeight int // Window dimensions
37− width, height int
38+ width, height int
38− keyMap KeyMap
39+ keyMap KeyMap
39
40
40 currentPage page.PageID
41 currentPage page.PageID
41 previousPage page.PageID
42 previousPage page.PageID
42 pages map[page.PageID]util.Model
43 pages map[page.PageID]util.Model
43 loadedPages map[page.PageID]bool
44 loadedPages map[page.PageID]bool
44
45
46+ // Status
45− status status.StatusCmp
47+ status status.StatusCmp
48+ showingFullHelp bool
46
49
47 app *app.App
50 app *app.App
48
51
49 dialog dialogs.DialogCmp
52 dialog dialogs.DialogCmp
50 completions completions.Completions
53 completions completions.Completions
51
54
52− // Session
55+ // Chat Page Specific
53 selectedSessionID string // The ID of the currently selected session
56 selectedSessionID string // The ID of the currently selected session
54− fullHelp bool // Whether to show full help text
55}
57}
56
58
57// Init initializes the application model and returns initial commands.
59// Init initializes the application model and returns initial commands.
@@ -99,7 +101,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99 )
101 )
100 return a, nil
102 return a, nil
101 case tea.WindowSizeMsg:
103 case tea.WindowSizeMsg:
102− return a, a.handleWindowResize(msg)
104+ return a, a.handleWindowResize(msg.Width, msg.Height)
103
105
104 // Completions messages
106 // Completions messages
105 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
107 case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
@@ -244,23 +246,29 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244}
246}
245
247
246// handleWindowResize processes window resize events and updates all components.
248// handleWindowResize processes window resize events and updates all components.
247−func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
249+func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
248 var cmds []tea.Cmd
250 var cmds []tea.Cmd
249− msg.Height -= 2 // Make space for the status bar
251+ a.wWidth, a.wHeight = width, height
252+ if a.showingFullHelp {
253+ height -= 3
254+ } else {
255+ height -= 2
256+ }
250− a.width, a.height = msg.Width, msg.Height
257+ a.width, a.height = width, height
251−
252 // Update status bar
258 // Update status bar
253− s, cmd := a.status.Update(msg)
259+ s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
254 a.status = s.(status.StatusCmp)
260 a.status = s.(status.StatusCmp)
255 cmds = append(cmds, cmd)
261 cmds = append(cmds, cmd)
256
262
257 // Update the current page
263 // Update the current page
258− updated, cmd := a.pages[a.currentPage].Update(msg)
264+ for p, page := range a.pages {
265+ updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
259− a.pages[a.currentPage] = updated.(util.Model)
266+ a.pages[p] = updated.(util.Model)
260− cmds = append(cmds, cmd)
267+ cmds = append(cmds, pageCmd)
268+ }
261
269
262 // Update the dialogs
270 // Update the dialogs
263− dialog, cmd := a.dialog.Update(msg)
271+ dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
264 a.dialog = dialog.(dialogs.DialogCmp)
272 a.dialog = dialog.(dialogs.DialogCmp)
265 cmds = append(cmds, cmd)
273 cmds = append(cmds, cmd)
266
274
@@ -288,6 +296,11 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
288 u, cmd := a.completions.Update(msg)
296 u, cmd := a.completions.Update(msg)
289 a.completions = u.(completions.Completions)
297 a.completions = u.(completions.Completions)
290 return cmd
298 return cmd
299+ // help
300+ case key.Matches(msg, a.keyMap.Help):
301+ a.status.ToggleFullHelp()
302+ a.showingFullHelp = !a.showingFullHelp
303+ return a.handleWindowResize(a.wWidth, a.wHeight)
291 // dialogs
304 // dialogs
292 case key.Matches(msg, a.keyMap.Quit):
305 case key.Matches(msg, a.keyMap.Quit):
293 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
306 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
@@ -345,7 +358,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
345// moveToPage handles navigation between different pages in the application.
358// moveToPage handles navigation between different pages in the application.
346func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
359func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
347 if a.app.CoderAgent.IsBusy() {
360 if a.app.CoderAgent.IsBusy() {
348− // For now we don't move to any page if the agent is busy
361+ // TODO: maybe remove this : For now we don't move to any page if the agent is busy
349 return util.ReportWarn("Agent is busy, please wait...")
362 return util.ReportWarn("Agent is busy, please wait...")
350 }
363 }
351
364
@@ -367,7 +380,12 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
367
380
368// View renders the complete application interface including pages, dialogs, and overlays.
381// View renders the complete application interface including pages, dialogs, and overlays.
369func (a *appModel) View() tea.View {
382func (a *appModel) View() tea.View {
370− pageView := a.pages[a.currentPage].View()
383+ page := a.pages[a.currentPage]
384+ if withHelp, ok := page.(layout.Help); ok {
385+ a.keyMap.pageBindings = withHelp.Bindings()
386+ }
387+ a.status.SetKeyMap(a.keyMap)
388+ pageView := page.View()
371 components := []string{
389 components := []string{
372 pageView.String(),
390 pageView.String(),
373 }
391 }
@@ -412,17 +430,20 @@ func (a *appModel) View() tea.View {
412
430
413// New creates and initializes a new TUI application model.
431// New creates and initializes a new TUI application model.
414func New(app *app.App) tea.Model {
432func New(app *app.App) tea.Model {
415− startPage := chat.ChatPage
433+ chatPage := chat.NewChatPage(app)
434+ keyMap := DefaultKeyMap()
435+ keyMap.pageBindings = chatPage.Bindings()
436+
416 model := &appModel{
437 model := &appModel{
417− currentPage: startPage,
438+ currentPage: chat.ChatPageID,
418 app: app,
439 app: app,
419− status: status.NewStatusCmp(),
440+ status: status.NewStatusCmp(keyMap),
420 loadedPages: make(map[page.PageID]bool),
441 loadedPages: make(map[page.PageID]bool),
421− keyMap: DefaultKeyMap(),
442+ keyMap: keyMap,
422
443
423 pages: map[page.PageID]util.Model{
444 pages: map[page.PageID]util.Model{
424− chat.ChatPage: chat.NewChatPage(app),
445+ chat.ChatPageID: chatPage,
425− logs.LogsPage: logs.NewLogsPage(),
446+ logs.LogsPage: logs.NewLogsPage(),
426 },
447 },
427
448
428 dialog: dialogs.NewDialogCmp(),
449 dialog: dialogs.NewDialogCmp(),
todos.md+16−7
@@ -1,11 +1,18 @@
1## TODOs before release
1## TODOs before release
2
2
3−- [~] Implement help
3+- [x] Implement help
4− - [ ] Show full help
4+ - [x] Show full help
5− - [ ] Make help dependent on the focused pane and page
5+ - [x] Make help dependent on the focused pane and page
6+- [ ] Implement current model in the sidebar
7+- [ ] Implement changed files
6- [ ] Events when tool error
8- [ ] Events when tool error
7−- [ ] Fix issue with numbers (padding)
9+- [ ] Support bash commands
8−- [ ] Fancy Spinner
10+- [ ] Editor attachments fixes
11+ - [ ] Reimplement removing attachments
12+- [ ] Fix the logs view
13+ - [ ] Review the implementation
14+ - [ ] The page lags
15+ - [ ] Make the logs long lived ?
9- [ ] Add all possible actions to the commands
16- [ ] Add all possible actions to the commands
10- [ ] Parallel tool calls and permissions
17- [ ] Parallel tool calls and permissions
11 - [ ] Run the tools in parallel and add results in parallel
18 - [ ] Run the tools in parallel and add results in parallel
@@ -14,14 +21,16 @@
14 - [ ] Weird behavior sometimes the message does not update
21 - [ ] Weird behavior sometimes the message does not update
15 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
22 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
16 - [ ] Address UX issues
23 - [ ] Address UX issues
17−- [ ] Implement current model in the sidebar
24+ - [ ] Fix issue with numbers (padding) view tool
18−- [ ] Implement changed files
19- [ ] Implement responsive mode
25- [ ] Implement responsive mode
20- [ ] Revisit the core list component
26- [ ] Revisit the core list component
21 - [ ] This component has become super complex we might need to fix this.
27 - [ ] This component has become super complex we might need to fix this.
28+- [ ] Investigate ways to make the spinner less CPU intensive
22- [ ] General cleanup and documentation
29- [ ] General cleanup and documentation
23- [ ] Update the readme
30- [ ] Update the readme
24
31
25## Maybe
32## Maybe
26
33
27- [ ] Revisit the provider/model/configs
34- [ ] Revisit the provider/model/configs
35+- [ ] Implement correct persistent shell
36+- [ ] Store file read/write time somewhere so that the we can make sure that even if we restart we do not need to re-read the same file