diff --git a/.gitignore b/.gitignore index 12ba99c77..abfcf698c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ vendor /cagent /cagent-* /docker-mcp-* +docker-agent diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index 74736983a..f49c783b2 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -132,6 +132,8 @@ Customize session titles to make them more meaningful and easier to find. By def | Enter | Send message (or newline with Shift+Enter) | | Up/Down | Navigate message history | +Press Ctrl+H to view the complete list of all available keyboard shortcuts. + ## History Search Press Ctrl+R to enter incremental history search mode. Start typing to filter through your previous inputs. Press Enter to select a match, or Escape to cancel. diff --git a/pkg/tui/dialog/help.go b/pkg/tui/dialog/help.go new file mode 100644 index 000000000..f0c0bbc2c --- /dev/null +++ b/pkg/tui/dialog/help.go @@ -0,0 +1,164 @@ +package dialog + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// helpDialog displays all currently active key bindings in a scrollable dialog. +type helpDialog struct { + readOnlyScrollDialog + + bindings []key.Binding +} + +// NewHelpDialog creates a new help dialog that displays all active key bindings. +func NewHelpDialog(bindings []key.Binding) Dialog { + d := &helpDialog{ + bindings: bindings, + } + d.readOnlyScrollDialog = newReadOnlyScrollDialog( + readOnlyScrollDialogSize{ + widthPercent: 70, + minWidth: 60, + maxWidth: 100, + heightPercent: 80, + heightMax: 40, + }, + d.renderContent, + ) + d.helpKeys = []string{"↑↓", "scroll", "Esc", "close"} + return d +} + +// renderContent renders the help dialog content. +func (d *helpDialog) renderContent(contentWidth, maxHeight int) []string { + titleStyle := styles.DialogTitleStyle + separatorStyle := styles.DialogSeparatorStyle + keyStyle := styles.DialogHelpStyle.Foreground(styles.TextSecondary).Bold(true) + descStyle := styles.DialogHelpStyle + + lines := []string{ + titleStyle.Render("Active Key Bindings"), + separatorStyle.Render(strings.Repeat("─", contentWidth)), + "", + } + + // Group bindings by category for better organization + // We'll do a simple categorization based on key prefixes + globalBindings := []key.Binding{} + ctrlBindings := []key.Binding{} + otherBindings := []key.Binding{} + + for _, binding := range d.bindings { + if len(binding.Keys()) == 0 { + continue + } + keyStr := binding.Keys()[0] + switch { + case strings.HasPrefix(keyStr, "ctrl+"): + ctrlBindings = append(ctrlBindings, binding) + case keyStr == "esc" || keyStr == "enter" || keyStr == "tab": + globalBindings = append(globalBindings, binding) + default: + otherBindings = append(otherBindings, binding) + } + } + + // Render global bindings + if len(globalBindings) > 0 { + lines = append(lines, + styles.DialogHelpStyle.Bold(true).Render("General"), + "", + ) + for _, binding := range globalBindings { + lines = append(lines, d.formatBinding(binding, keyStyle, descStyle)) + } + lines = append(lines, "") + } + + // Render ctrl bindings + if len(ctrlBindings) > 0 { + lines = append(lines, + styles.DialogHelpStyle.Bold(true).Render("Control Key Shortcuts"), + "", + ) + for _, binding := range ctrlBindings { + lines = append(lines, d.formatBinding(binding, keyStyle, descStyle)) + } + lines = append(lines, "") + } + + // Render other bindings + if len(otherBindings) > 0 { + lines = append(lines, + styles.DialogHelpStyle.Bold(true).Render("Other"), + "", + ) + for _, binding := range otherBindings { + lines = append(lines, d.formatBinding(binding, keyStyle, descStyle)) + } + } + + return lines +} + +// formatBinding formats a single key binding as " key description" +func (d *helpDialog) formatBinding(binding key.Binding, keyStyle, descStyle lipgloss.Style) string { + helpInfo := binding.Help() + helpKey := helpInfo.Key + helpDesc := helpInfo.Desc + + // Calculate spacing to align descriptions + const keyWidth = 20 + const indent = 2 + + keyPart := keyStyle.Render(helpKey) + descPart := descStyle.Render(helpDesc) + + // Pad the key part to align descriptions + keyPartWidth := lipgloss.Width(keyPart) + padding := strings.Repeat(" ", max(1, keyWidth-keyPartWidth)) + + return fmt.Sprintf("%s%s%s%s", + strings.Repeat(" ", indent), + keyPart, + padding, + descPart, + ) +} + +func (d *helpDialog) Init() tea.Cmd { + return d.readOnlyScrollDialog.Init() +} + +func (d *helpDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + model, cmd := d.readOnlyScrollDialog.Update(msg) + if rod, ok := model.(*readOnlyScrollDialog); ok { + d.readOnlyScrollDialog = *rod + } + return d, cmd +} + +func (d *helpDialog) View() string { + return d.readOnlyScrollDialog.View() +} + +func (d *helpDialog) Position() (int, int) { + return d.readOnlyScrollDialog.Position() +} + +func (d *helpDialog) SetSize(width, height int) tea.Cmd { + return d.readOnlyScrollDialog.SetSize(width, height) +} + +func (d *helpDialog) Bindings() []key.Binding { + return []key.Binding{} +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index a63520bc0..2ed89fcf5 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -1553,8 +1553,8 @@ func (m *appModel) Help() help.KeyMap { return core.NewSimpleHelp(m.Bindings()) } -// Bindings returns the key bindings shown in the status bar. -func (m *appModel) Bindings() []key.Binding { +// AllBindings returns ALL available key bindings for the help dialog (comprehensive list). +func (m *appModel) AllBindings() []key.Binding { quitBinding := key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("Ctrl+c", "quit"), @@ -1572,10 +1572,48 @@ func (m *appModel) Bindings() []key.Binding { bindings := []key.Binding{quitBinding, tabBinding} bindings = append(bindings, m.tabBar.Bindings()...) - bindings = append(bindings, key.NewBinding( - key.WithKeys("ctrl+k"), - key.WithHelp("Ctrl+k", "commands"), - )) + // Additional global shortcuts + bindings = append(bindings, + key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("Ctrl+k", "commands"), + ), + key.NewBinding( + key.WithKeys("ctrl+h"), + key.WithHelp("Ctrl+h", "help"), + ), + key.NewBinding( + key.WithKeys("ctrl+y"), + key.WithHelp("Ctrl+y", "toggle yolo mode"), + ), + key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("Ctrl+o", "toggle hide tool results"), + ), + key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("Ctrl+s", "cycle agent"), + ), + key.NewBinding( + key.WithKeys("ctrl+m"), + key.WithHelp("Ctrl+m", "model picker"), + ), + key.NewBinding( + key.WithKeys("ctrl+x"), + key.WithHelp("Ctrl+x", "clear queue"), + ), + key.NewBinding( + key.WithKeys("ctrl+z"), + key.WithHelp("Ctrl+z", "suspend"), + ), + ) + + if !m.leanMode { + bindings = append(bindings, key.NewBinding( + key.WithKeys("ctrl+b"), + key.WithHelp("Ctrl+b", "toggle sidebar"), + )) + } // Show newline help based on keyboard enhancement support if m.keyboardEnhancementsSupported { @@ -1608,6 +1646,47 @@ func (m *appModel) Bindings() []key.Binding { return bindings } +// Bindings returns the key bindings shown in the status bar (a curated subset). +// This filters AllBindings() to show only the most essential commands. +func (m *appModel) Bindings() []key.Binding { + all := m.AllBindings() + + // Define which keys should appear in the status bar + statusBarKeys := map[string]bool{ + "ctrl+c": true, // quit + "tab": true, // switch focus + "ctrl+t": true, // new tab (from tabBar) + "ctrl+w": true, // close tab (from tabBar) + "ctrl+p": true, // prev tab (from tabBar) + "ctrl+n": true, // next tab (from tabBar) + "ctrl+k": true, // commands + "ctrl+h": true, // help + "shift+enter": true, // newline + "ctrl+j": true, // newline fallback + "ctrl+g": true, // edit in external editor (editor context) + "ctrl+r": true, // history search (editor context) + // Content panel bindings (↑↓, c, e, d) are always included + "up": true, + "down": true, + "c": true, + "e": true, + "d": true, + } + + // Filter to only include status bar keys + var filtered []key.Binding + for _, binding := range all { + if len(binding.Keys()) > 0 { + bindingKey := binding.Keys()[0] + if statusBarKeys[bindingKey] { + filtered = append(filtered, binding) + } + } + } + + return filtered +} + // handleKeyPress handles all keyboard input with proper priority routing. func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Check if we should stop transcription on Enter or Escape @@ -1687,6 +1766,12 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+x"))): return m, core.CmdHandler(messages.ClearQueueMsg{}) + + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+h", "f1", "ctrl+?"))): + // Show contextual help dialog with ALL available key bindings + return m, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewHelpDialog(m.AllBindings()), + }) } // History search is a modal state — capture all remaining keys before normal routing