Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ vendor
/cagent
/cagent-*
/docker-mcp-*
docker-agent
2 changes: 2 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>H</kbd> to view the complete list of all available keyboard shortcuts.

## History Search

Press <kbd>Ctrl</kbd>+<kbd>R</kbd> to enter incremental history search mode. Start typing to filter through your previous inputs. Press <kbd>Enter</kbd> to select a match, or <kbd>Escape</kbd> to cancel.
Expand Down
164 changes: 164 additions & 0 deletions pkg/tui/dialog/help.go
Original file line number Diff line number Diff line change
@@ -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{}
}
97 changes: 91 additions & 6 deletions pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading