Merge origin/main into drop-reflection-and-use-string-builder

Raphael Amorim created

Resolved conflicts in keymap files by removing KeyBindings methods
and help implementations that were added in this branch but removed
in main branch.

Change summary

internal/llm/provider/openai.go                  |  2 
internal/llm/tools/shell/comparison_test.go      | 83 ++++++++++++++++++
internal/llm/tools/shell/shell.go                | 18 +++
internal/llm/tools/shell/shell_test.go           | 54 +++++++++++
internal/tui/components/chat/chat.go             | 18 ++-
internal/tui/components/chat/editor/editor.go    |  7 +
internal/tui/components/chat/editor/keys.go      | 42 ++------
internal/tui/components/core/layout/container.go |  6 
internal/tui/components/core/layout/layout.go    |  7 
internal/tui/components/core/layout/split.go     | 10 +-
internal/tui/components/core/list/keys.go        | 37 -------
internal/tui/components/core/status/keys.go      | 64 -------------
internal/tui/components/core/status/status.go    | 25 ++++-
internal/tui/keys.go                             | 68 +++++++++++---
internal/tui/page/chat/chat.go                   | 64 ++++++++++---
internal/tui/page/chat/keys.go                   | 46 +--------
internal/tui/page/logs/logs.go                   |  2 
internal/tui/tui.go                              | 67 +++++++++----
todos.md                                         | 23 +++-
19 files changed, 389 insertions(+), 254 deletions(-)

Detailed changes

internal/llm/provider/openai.go 🔗

@@ -166,7 +166,7 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
 		Tools:    tools,
 	}
 
-	if o.providerOptions.model.CanReason == true {
+	if o.providerOptions.model.CanReason {
 		params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
 		switch o.options.reasoningEffort {
 		case "low":

internal/llm/tools/shell/comparison_test.go 🔗

@@ -0,0 +1,83 @@
+package shell
+
+import (
+	"context"
+	"os"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestShellPerformanceComparison(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "shell-test")
+	require.NoError(t, err)
+	defer os.RemoveAll(tmpDir)
+
+	shell := GetPersistentShell(tmpDir)
+	defer shell.Close()
+
+	// Test quick command
+	start := time.Now()
+	stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello'", 0)
+	duration := time.Since(start)
+
+	require.NoError(t, err)
+	assert.Equal(t, 0, exitCode)
+	assert.Contains(t, stdout, "hello")
+	assert.Empty(t, stderr)
+
+	t.Logf("Quick command took: %v", duration)
+}
+
+func TestShellCPUUsageComparison(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "shell-test")
+	require.NoError(t, err)
+	defer os.RemoveAll(tmpDir)
+
+	shell := GetPersistentShell(tmpDir)
+	defer shell.Close()
+
+	// Measure CPU and memory usage during a longer command
+	var m1, m2 runtime.MemStats
+	runtime.GC()
+	runtime.ReadMemStats(&m1)
+
+	start := time.Now()
+	_, stderr, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.1", 1000)
+	duration := time.Since(start)
+
+	runtime.ReadMemStats(&m2)
+
+	require.NoError(t, err)
+	assert.Equal(t, 0, exitCode)
+	assert.Empty(t, stderr)
+
+	memGrowth := m2.Alloc - m1.Alloc
+	t.Logf("Sleep 0.1s command took: %v", duration)
+	t.Logf("Memory growth during polling: %d bytes", memGrowth)
+	t.Logf("GC cycles during test: %d", m2.NumGC-m1.NumGC)
+}
+
+// Benchmark CPU usage during polling
+func BenchmarkShellPolling(b *testing.B) {
+	tmpDir, err := os.MkdirTemp("", "shell-bench")
+	require.NoError(b, err)
+	defer os.RemoveAll(tmpDir)
+
+	shell := GetPersistentShell(tmpDir)
+	defer shell.Close()
+
+	b.ResetTimer()
+	b.ReportAllocs()
+
+	for i := 0; i < b.N; i++ {
+		// Use a short sleep to measure polling overhead
+		_, _, exitCode, _, err := shell.Exec(context.Background(), "sleep 0.02", 500)
+		if err != nil || exitCode != 0 {
+			b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
+		}
+	}
+}

internal/llm/tools/shell/shell.go 🔗

@@ -189,6 +189,13 @@ echo $EXEC_EXIT_CODE > %s
 
 	done := make(chan bool)
 	go func() {
+		// Use exponential backoff polling
+		pollInterval := 1 * time.Millisecond
+		maxPollInterval := 100 * time.Millisecond
+
+		ticker := time.NewTicker(pollInterval)
+		defer ticker.Stop()
+
 		for {
 			select {
 			case <-ctx.Done():
@@ -197,7 +204,7 @@ echo $EXEC_EXIT_CODE > %s
 				done <- true
 				return
 
-			case <-time.After(10 * time.Millisecond):
+			case <-ticker.C:
 				if fileExists(statusFile) && fileSize(statusFile) > 0 {
 					done <- true
 					return
@@ -212,6 +219,15 @@ echo $EXEC_EXIT_CODE > %s
 						return
 					}
 				}
+
+				// Exponential backoff to reduce CPU usage for longer-running commands
+				if pollInterval < maxPollInterval {
+					pollInterval = time.Duration(float64(pollInterval) * 1.5)
+					if pollInterval > maxPollInterval {
+						pollInterval = maxPollInterval
+					}
+					ticker.Reset(pollInterval)
+				}
 			}
 		}
 	}()

internal/llm/tools/shell/shell_test.go 🔗

@@ -0,0 +1,54 @@
+package shell
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestShellPerformanceImprovement(t *testing.T) {
+	// Create a temporary directory for the shell
+	tmpDir, err := os.MkdirTemp("", "shell-test")
+	require.NoError(t, err)
+	defer os.RemoveAll(tmpDir)
+
+	shell := GetPersistentShell(tmpDir)
+	defer shell.Close()
+
+	// Test that quick commands complete fast
+	start := time.Now()
+	stdout, stderr, exitCode, _, err := shell.Exec(context.Background(), "echo 'hello world'", 0)
+	duration := time.Since(start)
+
+	require.NoError(t, err)
+	assert.Equal(t, 0, exitCode)
+	assert.Contains(t, stdout, "hello world")
+	assert.Empty(t, stderr)
+
+	// Quick commands should complete very fast with our exponential backoff
+	assert.Less(t, duration, 50*time.Millisecond, "Quick command should complete fast with exponential backoff")
+}
+
+// Benchmark to measure CPU efficiency
+func BenchmarkShellQuickCommands(b *testing.B) {
+	tmpDir, err := os.MkdirTemp("", "shell-bench")
+	require.NoError(b, err)
+	defer os.RemoveAll(tmpDir)
+
+	shell := GetPersistentShell(tmpDir)
+	defer shell.Close()
+
+	b.ResetTimer()
+	b.ReportAllocs()
+
+	for i := 0; i < b.N; i++ {
+		_, _, exitCode, _, err := shell.Exec(context.Background(), "echo test", 0)
+		if err != nil || exitCode != 0 {
+			b.Fatalf("Command failed: %v, exit code: %d", err, exitCode)
+		}
+	}
+}

internal/tui/components/chat/chat.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"time"
 
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/llm/agent"
@@ -49,21 +50,23 @@ type messageListCmp struct {
 	previousSelected int // Last selected item index for restoring focus
 
 	lastUserMessageTime int64
+	defaultListKeyMap   list.KeyMap
 }
 
 // NewMessagesListCmp creates a new message list component with custom keybindings
 // and reverse ordering (newest messages at bottom).
 func NewMessagesListCmp(app *app.App) MessageListCmp {
-	defaultKeymaps := list.DefaultKeyMap()
+	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
 		list.WithGapSize(1),
 		list.WithReverse(true),
-		list.WithKeyMap(defaultKeymaps),
+		list.WithKeyMap(defaultListKeyMap),
 	)
 	return &messageListCmp{
-		app:              app,
-		listCmp:          listCmp,
-		previousSelected: list.NoSelection,
+		app:               app,
+		listCmp:           listCmp,
+		previousSelected:  list.NoSelection,
+		defaultListKeyMap: defaultListKeyMap,
 	}
 }
 
@@ -495,3 +498,8 @@ func (m *messageListCmp) Focus() tea.Cmd {
 func (m *messageListCmp) IsFocused() bool {
 	return m.listCmp.IsFocused()
 }
+
+func (m *messageListCmp) Bindings() []key.Binding {
+	bindings := layout.KeyMapToSlice(m.defaultListKeyMap)
+	return bindings
+}

internal/tui/components/chat/editor/editor.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -248,7 +249,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		// Hanlde Enter key
-		if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
+		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
 			value := m.textarea.Value()
 			if len(value) > 0 && value[len(value)-1] == '\\' {
 				// If the last character is a backslash, remove it and add a newline
@@ -370,6 +371,10 @@ func (c *editorCmp) IsFocused() bool {
 	return c.textarea.Focused()
 }
 
+func (c *editorCmp) Bindings() []key.Binding {
+	return layout.KeyMapToSlice(c.keyMap)
+}
+
 func NewEditorCmp(app *app.App) util.Model {
 	t := styles.CurrentTheme()
 	ta := textarea.New()

internal/tui/components/chat/editor/keys.go 🔗

@@ -5,13 +5,18 @@ import (
 )
 
 type EditorKeyMap struct {
-	Send       key.Binding
-	OpenEditor key.Binding
+	AddFile     key.Binding
+	SendMessage key.Binding
+	OpenEditor  key.Binding
 }
 
 func DefaultEditorKeyMap() EditorKeyMap {
 	return EditorKeyMap{
-		Send: key.NewBinding(
+		AddFile: key.NewBinding(
+			key.WithKeys("/"),
+			key.WithHelp("/", "add file"),
+		),
+		SendMessage: key.NewBinding(
 			key.WithKeys("enter"),
 			key.WithHelp("enter", "send"),
 		),
@@ -22,31 +27,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
 	}
 }
 
-// KeyBindings implements layout.KeyMapProvider
-func (k EditorKeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Send,
-		k.OpenEditor,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k EditorKeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k EditorKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Send,
-		k.OpenEditor,
-	}
+type DeleteAttachmentKeyMaps struct {
+	AttachmentDeleteMode key.Binding
+	Escape               key.Binding
+	DeleteAllAttachments key.Binding
 }
 
 // TODO: update this to use the new keymap concepts
@@ -63,4 +47,4 @@ var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
 		key.WithKeys("r"),
 		key.WithHelp("ctrl+r+r", "delete all attachments"),
 	),
-}
+}

internal/tui/components/core/layout/container.go 🔗

@@ -1,7 +1,7 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -157,9 +157,9 @@ func (c *container) SetPosition(x, y int) tea.Cmd {
 	return nil
 }
 
-func (c *container) Help() help.KeyMap {
+func (c *container) Bindings() []key.Binding {
 	if b, ok := c.content.(Help); ok {
-		return b.Help()
+		return b.Bindings()
 	}
 	return nil
 }

internal/tui/components/core/layout/layout.go 🔗

@@ -1,7 +1,8 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/help"
+	"reflect"
+
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 )
@@ -18,7 +19,7 @@ type Sizeable interface {
 }
 
 type Help interface {
-	Help() help.KeyMap
+	Bindings() []key.Binding
 }
 
 type Positionable interface {
@@ -28,4 +29,4 @@ type Positionable interface {
 // KeyMapProvider defines an interface for types that can provide their key bindings as a slice
 type KeyMapProvider interface {
 	KeyBindings() []key.Binding
-}
+}

internal/tui/components/core/layout/split.go 🔗

@@ -1,7 +1,7 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -269,20 +269,20 @@ func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
 	return nil
 }
 
-func (s *splitPaneLayout) Help() help.KeyMap {
+func (s *splitPaneLayout) Bindings() []key.Binding {
 	if s.leftPanel != nil {
 		if b, ok := s.leftPanel.(Help); ok && s.leftPanel.IsFocused() {
-			return b.Help()
+			return b.Bindings()
 		}
 	}
 	if s.rightPanel != nil {
 		if b, ok := s.rightPanel.(Help); ok && s.rightPanel.IsFocused() {
-			return b.Help()
+			return b.Bindings()
 		}
 	}
 	if s.bottomPanel != nil {
 		if b, ok := s.bottomPanel.(Help); ok && s.bottomPanel.IsFocused() {
-			return b.Help()
+			return b.Bindings()
 		}
 	}
 	return nil

internal/tui/components/core/list/keys.go 🔗

@@ -50,39 +50,4 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("shift+g", "end"),
 		),
 	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Down,
-		k.Up,
-		k.NDown,
-		k.NUp,
-		k.DownOneItem,
-		k.UpOneItem,
-		k.HalfPageDown,
-		k.HalfPageUp,
-		k.Home,
-		k.End,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Up,
-		k.Down,
-	}
-}
+}

internal/tui/components/core/status/keys.go 🔗

@@ -1,64 +0,0 @@
-package status
-
-import (
-	"github.com/charmbracelet/bubbles/v2/key"
-)
-
-type KeyMap struct {
-	Tab,
-	Commands,
-	Sessions,
-	Help key.Binding
-}
-
-func DefaultKeyMap(tabHelp string) KeyMap {
-	return KeyMap{
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", tabHelp),
-		),
-		Commands: key.NewBinding(
-			key.WithKeys("ctrl+p"),
-			key.WithHelp("ctrl+p", "commands"),
-		),
-		Sessions: key.NewBinding(
-			key.WithKeys("ctrl+s"),
-			key.WithHelp("ctrl+s", "sessions"),
-		),
-		Help: key.NewBinding(
-			key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
-			key.WithHelp("ctrl+?", "more"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Tab,
-		k.Commands,
-		k.Sessions,
-		k.Help,
-	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Tab,
-		k.Commands,
-		k.Sessions,
-		k.Help,
-	}
-}

internal/tui/components/core/status/status.go 🔗

@@ -14,6 +14,8 @@ import (
 
 type StatusCmp interface {
 	util.Model
+	ToggleFullHelp()
+	SetKeyMap(keyMap help.KeyMap)
 }
 
 type statusCmp struct {
@@ -22,23 +24,25 @@ type statusCmp struct {
 	messageTTL time.Duration
 	session    session.Session
 	help       help.Model
+	keyMap     help.KeyMap
 }
 
 // clearMessageCmd is a command that clears status messages after a timeout
-func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
+func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
 	return tea.Tick(ttl, func(time.Time) tea.Msg {
 		return util.ClearStatusMsg{}
 	})
 }
 
-func (m statusCmp) Init() tea.Cmd {
+func (m *statusCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
+		m.help.Width = msg.Width - 2
 		return m, nil
 
 	// Handle status info
@@ -86,9 +90,9 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
-func (m statusCmp) View() tea.View {
+func (m *statusCmp) View() tea.View {
 	t := styles.CurrentTheme()
-	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
 	if m.info.Msg != "" {
 		switch m.info.Type {
 		case util.InfoTypeError:
@@ -102,12 +106,21 @@ func (m statusCmp) View() tea.View {
 	return tea.NewView(status)
 }
 
-func NewStatusCmp() StatusCmp {
+func (m *statusCmp) ToggleFullHelp() {
+	m.help.ShowAll = !m.help.ShowAll
+}
+
+func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
+	m.keyMap = keyMap
+}
+
+func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
 	t := styles.CurrentTheme()
 	help := help.New()
 	help.Styles = t.S().Help
 	return &statusCmp{
 		messageTTL: 10 * time.Second,
 		help:       help,
+		keyMap:     keyMap,
 	}
 }

internal/tui/keys.go 🔗

@@ -10,6 +10,8 @@ type KeyMap struct {
 	Help     key.Binding
 	Commands key.Binding
 	Sessions key.Binding
+
+	pageBindings []key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -22,10 +24,9 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+c"),
 			key.WithHelp("ctrl+c", "quit"),
 		),
-
 		Help: key.NewBinding(
-			key.WithKeys("ctrl+_"),
-			key.WithHelp("ctrl+?", "toggle help"),
+			key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
+			key.WithHelp("ctrl+?", "more"),
 		),
 		Commands: key.NewBinding(
 			key.WithKeys("ctrl+p"),
@@ -41,26 +42,59 @@ func DefaultKeyMap() KeyMap {
 // FullHelp implements help.KeyMap.
 func (k KeyMap) FullHelp() [][]key.Binding {
 	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
+	slice := []key.Binding{
+		k.Commands,
+		k.Sessions,
+		k.Quit,
+		k.Help,
+		k.Logs,
+	}
+	slice = k.prependEscAndTab(slice)
+	slice = append(slice, k.pageBindings...)
+	// remove duplicates
+	seen := make(map[string]bool)
+	cleaned := []key.Binding{}
+	for _, b := range slice {
+		if !seen[b.Help().Key] {
+			seen[b.Help().Key] = true
+			cleaned = append(cleaned, b)
+		}
+	}
+
+	for i := 0; i < len(cleaned); i += 2 {
+		end := min(i+2, len(cleaned))
+		m = append(m, cleaned[i:end])
 	}
 	return m
 }
 
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.Logs,
-		k.Quit,
-		k.Help,
-		k.Commands,
-		k.Sessions,
+func (k KeyMap) prependEscAndTab(bindings []key.Binding) []key.Binding {
+	var cancel key.Binding
+	var tab key.Binding
+	for _, b := range k.pageBindings {
+		if b.Help().Key == "esc" {
+			cancel = b
+		}
+		if b.Help().Key == "tab" {
+			tab = b
+		}
 	}
+	if tab.Help().Key != "" {
+		bindings = append([]key.Binding{tab}, bindings...)
+	}
+	if cancel.Help().Key != "" {
+		bindings = append([]key.Binding{cancel}, bindings...)
+	}
+	return bindings
 }
 
 // ShortHelp implements help.KeyMap.
 func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{}
-}
+	bindings := []key.Binding{
+		k.Commands,
+		k.Sessions,
+		k.Quit,
+		k.Help,
+	}
+	return k.prependEscAndTab(bindings)
+}

internal/tui/page/chat/chat.go 🔗

@@ -19,26 +19,31 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
-var ChatPage page.PageID = "chat"
-
-type ChatFocusedMsg struct {
-	Focused bool // True if the chat input is focused, false otherwise
-}
+var ChatPageID page.PageID = "chat"
 
 type (
 	OpenFilePickerMsg struct{}
-	chatPage          struct {
-		app *app.App
+	ChatFocusedMsg    struct {
+		Focused bool // True if the chat input is focused, false otherwise
+	}
+)
 
-		layout layout.SplitPaneLayout
+type ChatPage interface {
+	util.Model
+	layout.Help
+}
 
-		session session.Session
+type chatPage struct {
+	app *app.App
 
-		keyMap KeyMap
+	layout layout.SplitPaneLayout
 
-		chatFocused bool
-	}
-)
+	session session.Session
+
+	keyMap KeyMap
+
+	chatFocused bool
+}
 
 func (p *chatPage) Init() tea.Cmd {
 	cmd := p.layout.Init()
@@ -87,7 +92,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				util.CmdHandler(chat.SessionClearedMsg{}),
 			)
 
-		case key.Matches(msg, p.keyMap.FilePicker):
+		case key.Matches(msg, p.keyMap.AddAttachment):
 			cfg := config.Get()
 			agentCfg := cfg.Agents[config.AgentCoder]
 			selectedModelID := agentCfg.Model
@@ -173,7 +178,36 @@ func (p *chatPage) View() tea.View {
 	return p.layout.View()
 }
 
-func NewChatPage(app *app.App) util.Model {
+func (p *chatPage) Bindings() []key.Binding {
+	bindings := []key.Binding{
+		p.keyMap.NewSession,
+		p.keyMap.AddAttachment,
+	}
+	if p.app.CoderAgent.IsBusy() {
+		bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
+	}
+
+	if p.chatFocused {
+		bindings = append([]key.Binding{
+			key.NewBinding(
+				key.WithKeys("tab"),
+				key.WithHelp("tab", "focus editor"),
+			),
+		}, bindings...)
+	} else {
+		bindings = append([]key.Binding{
+			key.NewBinding(
+				key.WithKeys("tab"),
+				key.WithHelp("tab", "focus chat"),
+			),
+		}, bindings...)
+	}
+
+	bindings = append(bindings, p.layout.Bindings()...)
+	return bindings
+}
+
+func NewChatPage(app *app.App) ChatPage {
 	sidebarContainer := layout.NewContainer(
 		sidebar.NewSidebarCmp(),
 		layout.WithPadding(1, 1, 1, 1),

internal/tui/page/chat/keys.go 🔗

@@ -5,10 +5,10 @@ import (
 )
 
 type KeyMap struct {
-	NewSession key.Binding
-	FilePicker key.Binding
-	Cancel     key.Binding
-	Tab        key.Binding
+	NewSession    key.Binding
+	AddAttachment key.Binding
+	Cancel        key.Binding
+	Tab           key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -17,6 +17,10 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+n"),
 			key.WithHelp("ctrl+n", "new session"),
 		),
+		AddAttachment: key.NewBinding(
+			key.WithKeys("ctrl+f"),
+			key.WithHelp("ctrl+f", "add attachment"),
+		),
 		Cancel: key.NewBinding(
 			key.WithKeys("esc"),
 			key.WithHelp("esc", "cancel"),
@@ -25,37 +29,5 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("tab"),
 			key.WithHelp("tab", "change focus"),
 		),
-		FilePicker: key.NewBinding(
-			key.WithKeys("ctrl+f"),
-			key.WithHelp("ctrl+f", "select files to upload"),
-		),
-	}
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
-	return []key.Binding{
-		k.NewSession,
-		k.FilePicker,
-		k.Cancel,
-		k.Tab,
 	}
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
-	m := [][]key.Binding{}
-	slice := k.KeyBindings()
-	for i := 0; i < len(slice); i += 4 {
-		end := min(i+4, len(slice))
-		m = append(m, slice[i:end])
-	}
-	return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		k.Tab,
-	}
-}
+}

internal/tui/page/logs/logs.go 🔗

@@ -37,7 +37,7 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, p.keyMap.Back):
-			return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPage})
+			return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPageID})
 		}
 	}
 

internal/tui/tui.go 🔗

@@ -34,24 +34,26 @@ import (
 
 // appModel represents the main application model that manages pages, dialogs, and UI state.
 type appModel struct {
-	width, height int
-	keyMap        KeyMap
+	wWidth, wHeight int // Window dimensions
+	width, height   int
+	keyMap          KeyMap
 
 	currentPage  page.PageID
 	previousPage page.PageID
 	pages        map[page.PageID]util.Model
 	loadedPages  map[page.PageID]bool
 
-	status status.StatusCmp
+	// Status
+	status          status.StatusCmp
+	showingFullHelp bool
 
 	app *app.App
 
 	dialog      dialogs.DialogCmp
 	completions completions.Completions
 
-	// Session
+	// Chat Page Specific
 	selectedSessionID string // The ID of the currently selected session
-	fullHelp          bool   // Whether to show full help text
 }
 
 // Init initializes the application model and returns initial commands.
@@ -99,7 +101,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		)
 		return a, nil
 	case tea.WindowSizeMsg:
-		return a, a.handleWindowResize(msg)
+		return a, a.handleWindowResize(msg.Width, msg.Height)
 
 	// Completions messages
 	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
@@ -244,23 +246,29 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // handleWindowResize processes window resize events and updates all components.
-func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 	var cmds []tea.Cmd
-	msg.Height -= 2 // Make space for the status bar
-	a.width, a.height = msg.Width, msg.Height
-
+	a.wWidth, a.wHeight = width, height
+	if a.showingFullHelp {
+		height -= 3
+	} else {
+		height -= 2
+	}
+	a.width, a.height = width, height
 	// Update status bar
-	s, cmd := a.status.Update(msg)
+	s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
 	a.status = s.(status.StatusCmp)
 	cmds = append(cmds, cmd)
 
 	// Update the current page
-	updated, cmd := a.pages[a.currentPage].Update(msg)
-	a.pages[a.currentPage] = updated.(util.Model)
-	cmds = append(cmds, cmd)
+	for p, page := range a.pages {
+		updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
+		a.pages[p] = updated.(util.Model)
+		cmds = append(cmds, pageCmd)
+	}
 
 	// Update the dialogs
-	dialog, cmd := a.dialog.Update(msg)
+	dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
 	a.dialog = dialog.(dialogs.DialogCmp)
 	cmds = append(cmds, cmd)
 
@@ -288,6 +296,11 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		u, cmd := a.completions.Update(msg)
 		a.completions = u.(completions.Completions)
 		return cmd
+		// help
+	case key.Matches(msg, a.keyMap.Help):
+		a.status.ToggleFullHelp()
+		a.showingFullHelp = !a.showingFullHelp
+		return a.handleWindowResize(a.wWidth, a.wHeight)
 	// dialogs
 	case key.Matches(msg, a.keyMap.Quit):
 		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
@@ -345,7 +358,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 // moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {
-		// For now we don't move to any page if the agent is busy
+		// TODO: maybe remove this :  For now we don't move to any page if the agent is busy
 		return util.ReportWarn("Agent is busy, please wait...")
 	}
 
@@ -367,7 +380,12 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 
 // View renders the complete application interface including pages, dialogs, and overlays.
 func (a *appModel) View() tea.View {
-	pageView := a.pages[a.currentPage].View()
+	page := a.pages[a.currentPage]
+	if withHelp, ok := page.(layout.Help); ok {
+		a.keyMap.pageBindings = withHelp.Bindings()
+	}
+	a.status.SetKeyMap(a.keyMap)
+	pageView := page.View()
 	components := []string{
 		pageView.String(),
 	}
@@ -412,17 +430,20 @@ func (a *appModel) View() tea.View {
 
 // New creates and initializes a new TUI application model.
 func New(app *app.App) tea.Model {
-	startPage := chat.ChatPage
+	chatPage := chat.NewChatPage(app)
+	keyMap := DefaultKeyMap()
+	keyMap.pageBindings = chatPage.Bindings()
+
 	model := &appModel{
-		currentPage: startPage,
+		currentPage: chat.ChatPageID,
 		app:         app,
-		status:      status.NewStatusCmp(),
+		status:      status.NewStatusCmp(keyMap),
 		loadedPages: make(map[page.PageID]bool),
-		keyMap:      DefaultKeyMap(),
+		keyMap:      keyMap,
 
 		pages: map[page.PageID]util.Model{
-			chat.ChatPage: chat.NewChatPage(app),
-			logs.LogsPage: logs.NewLogsPage(),
+			chat.ChatPageID: chatPage,
+			logs.LogsPage:   logs.NewLogsPage(),
 		},
 
 		dialog:      dialogs.NewDialogCmp(),

todos.md 🔗

@@ -1,11 +1,18 @@
 ## TODOs before release
 
-- [~] Implement help
-  - [ ] Show full help
-  - [ ] Make help dependent on the focused pane and page
+- [x] Implement help
+  - [x] Show full help
+  - [x] Make help dependent on the focused pane and page
+- [ ] Implement current model in the sidebar
+- [ ] Implement changed files
 - [ ] Events when tool error
-- [ ] Fix issue with numbers (padding)
-- [ ] Fancy Spinner
+- [ ] Support bash commands
+- [ ] Editor attachments fixes
+  - [ ] Reimplement removing attachments
+- [ ] Fix the logs view
+  - [ ] Review the implementation
+  - [ ] The page lags
+  - [ ] Make the logs long lived ?
 - [ ] Add all possible actions to the commands
 - [ ] Parallel tool calls and permissions
   - [ ] Run the tools in parallel and add results in parallel
@@ -14,14 +21,16 @@
   - [ ] Weird behavior sometimes the message does not update
   - [ ] Message length (I saw the message go beyond the correct length when there are errors)
   - [ ] Address UX issues
-- [ ] Implement current model in the sidebar
-- [ ] Implement changed files
+  - [ ] Fix issue with numbers (padding) view tool
 - [ ] Implement responsive mode
 - [ ] Revisit the core list component
   - [ ] This component has become super complex we might need to fix this.
+- [ ] Investigate ways to make the spinner less CPU intensive
 - [ ] General cleanup and documentation
 - [ ] Update the readme
 
 ## Maybe
 
 - [ ] Revisit the provider/model/configs
+- [ ] Implement correct persistent shell
+- [ ] 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