diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 672ef1eb6b36bf65a8db8491cefbe83e8272845a..aa917ca40620c386d1b5b27ea6f6197260697b2e 100644 --- a/internal/llm/provider/openai.go +++ b/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": diff --git a/internal/llm/tools/shell/comparison_test.go b/internal/llm/tools/shell/comparison_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7fcd720b5ecdfba236ed7316dfc26b63d5ff9605 --- /dev/null +++ b/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) + } + } +} diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index fffe8fcfe73894f30790c6a21be402332af21c9c..6c94904c9584c8d1500130196fef10cad202620d 100644 --- a/internal/llm/tools/shell/shell.go +++ b/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) + } } } }() diff --git a/internal/llm/tools/shell/shell_test.go b/internal/llm/tools/shell/shell_test.go new file mode 100644 index 0000000000000000000000000000000000000000..327ec91db5f2cdffdbb501648df1546e4746fabb --- /dev/null +++ b/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) + } + } +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 9aa7ef1037b2b99f23e15ce903a4f4b264fd35c1..d0c83857e5ea95ee582371d14c9ea1724669c60d 100644 --- a/internal/tui/components/chat/chat.go +++ b/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 +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index e47057f7ab7c2890cc3d97b3b3e94d567e7994ef..2f7e02b676512a226f8da5f25329a937c13c64dc 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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() diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 0f85249645b976e861a6e9bdff61b7fbebcdbd50..07a8597db73c997b32b20ee1094a7b8ed12987d4 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/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"), ), -} +} \ No newline at end of file diff --git a/internal/tui/components/core/layout/container.go b/internal/tui/components/core/layout/container.go index b0444130140d984051d4def0a17ea43f196050dd..02ae5c68d5aa885f56fb598127b358702a41e4c0 100644 --- a/internal/tui/components/core/layout/container.go +++ b/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 } diff --git a/internal/tui/components/core/layout/layout.go b/internal/tui/components/core/layout/layout.go index a0d0b9c0326de3d6a263eb1707f99b8f61a72b95..ba691e5df6ad84c5b0bc0f32cbad7d5611268b25 100644 --- a/internal/tui/components/core/layout/layout.go +++ b/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 -} +} \ No newline at end of file diff --git a/internal/tui/components/core/layout/split.go b/internal/tui/components/core/layout/split.go index d5c66ae0616d6ee4039e6e9e0933242d0c6daa28..9af08f48d47c4d3a1e2283fabf9d1634b291da35 100644 --- a/internal/tui/components/core/layout/split.go +++ b/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 diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 9cc628ab912d43aadde09a863d389cdb5dc618ba..da7c1661a40cb1f6284b0da1fd32c53a0c004d25 100644 --- a/internal/tui/components/core/list/keys.go +++ b/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, - } -} +} \ No newline at end of file diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go deleted file mode 100644 index d33abe2d2102ef88bfba6ce8c2d89ea66bd95282..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status/keys.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index ef5ebef108e9252aefa353fddefdde3538a28497..7b91c186f7ab9e572685de3e346204873d8cede2 100644 --- a/internal/tui/components/core/status/status.go +++ b/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, } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index e36e264f36ca720f717874632e2607006140fbb8..34f40e88c9a083e528496105945ab550b5bb10f2 100644 --- a/internal/tui/keys.go +++ b/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) +} \ No newline at end of file diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index e02996b1ff4ba0e97ddda05d469ec9f8fca4ab5c..fa374abb0bee72330d840b858b5219d82554b5ed 100644 --- a/internal/tui/page/chat/chat.go +++ b/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), diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go index 61e8023145262fed2c0a6455dd11ba2b228405d7..899ff1d77f25373bfc5421b9652926f0eb2731bf 100644 --- a/internal/tui/page/chat/keys.go +++ b/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, - } -} +} \ No newline at end of file diff --git a/internal/tui/page/logs/logs.go b/internal/tui/page/logs/logs.go index 228875a196434d0f8dfb8cfe15783c4d2b1fd618..e8b1077d80094ee5974debc5c09f1f82562e334c 100644 --- a/internal/tui/page/logs/logs.go +++ b/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}) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a4c2e48f58e1cfc34b1ce12788885bd1731894d8..50e221a1520c446169fce544b494285f03a1e778 100644 --- a/internal/tui/tui.go +++ b/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(), diff --git a/todos.md b/todos.md index 90dadc5c7a469c067a246fc4c926d268af62afd5..85ce7a39c019fe86508888c1254049091ff87e2c 100644 --- a/todos.md +++ b/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