Skip to content

Commit 99fe1b0

Browse files
authored
Merge pull request #2305 from masegraye/feature/contextual-help-dialog
Add contextual help dialog (Ctrl+H)
2 parents d58a3d4 + 6c0bd71 commit 99fe1b0

File tree

4 files changed

+258
-6
lines changed

4 files changed

+258
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ vendor
2222
/cagent
2323
/cagent-*
2424
/docker-mcp-*
25+
docker-agent

docs/features/tui/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Customize session titles to make them more meaningful and easier to find. By def
132132
| Enter | Send message (or newline with Shift+Enter) |
133133
| Up/Down | Navigate message history |
134134

135+
Press <kbd>Ctrl</kbd>+<kbd>H</kbd> to view the complete list of all available keyboard shortcuts.
136+
135137
## History Search
136138

137139
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.

pkg/tui/dialog/help.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package dialog
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"charm.land/bubbles/v2/key"
8+
tea "charm.land/bubbletea/v2"
9+
"charm.land/lipgloss/v2"
10+
11+
"github.com/docker/docker-agent/pkg/tui/core/layout"
12+
"github.com/docker/docker-agent/pkg/tui/styles"
13+
)
14+
15+
// helpDialog displays all currently active key bindings in a scrollable dialog.
16+
type helpDialog struct {
17+
readOnlyScrollDialog
18+
19+
bindings []key.Binding
20+
}
21+
22+
// NewHelpDialog creates a new help dialog that displays all active key bindings.
23+
func NewHelpDialog(bindings []key.Binding) Dialog {
24+
d := &helpDialog{
25+
bindings: bindings,
26+
}
27+
d.readOnlyScrollDialog = newReadOnlyScrollDialog(
28+
readOnlyScrollDialogSize{
29+
widthPercent: 70,
30+
minWidth: 60,
31+
maxWidth: 100,
32+
heightPercent: 80,
33+
heightMax: 40,
34+
},
35+
d.renderContent,
36+
)
37+
d.helpKeys = []string{"↑↓", "scroll", "Esc", "close"}
38+
return d
39+
}
40+
41+
// renderContent renders the help dialog content.
42+
func (d *helpDialog) renderContent(contentWidth, maxHeight int) []string {
43+
titleStyle := styles.DialogTitleStyle
44+
separatorStyle := styles.DialogSeparatorStyle
45+
keyStyle := styles.DialogHelpStyle.Foreground(styles.TextSecondary).Bold(true)
46+
descStyle := styles.DialogHelpStyle
47+
48+
lines := []string{
49+
titleStyle.Render("Active Key Bindings"),
50+
separatorStyle.Render(strings.Repeat("─", contentWidth)),
51+
"",
52+
}
53+
54+
// Group bindings by category for better organization
55+
// We'll do a simple categorization based on key prefixes
56+
globalBindings := []key.Binding{}
57+
ctrlBindings := []key.Binding{}
58+
otherBindings := []key.Binding{}
59+
60+
for _, binding := range d.bindings {
61+
if len(binding.Keys()) == 0 {
62+
continue
63+
}
64+
keyStr := binding.Keys()[0]
65+
switch {
66+
case strings.HasPrefix(keyStr, "ctrl+"):
67+
ctrlBindings = append(ctrlBindings, binding)
68+
case keyStr == "esc" || keyStr == "enter" || keyStr == "tab":
69+
globalBindings = append(globalBindings, binding)
70+
default:
71+
otherBindings = append(otherBindings, binding)
72+
}
73+
}
74+
75+
// Render global bindings
76+
if len(globalBindings) > 0 {
77+
lines = append(lines,
78+
styles.DialogHelpStyle.Bold(true).Render("General"),
79+
"",
80+
)
81+
for _, binding := range globalBindings {
82+
lines = append(lines, d.formatBinding(binding, keyStyle, descStyle))
83+
}
84+
lines = append(lines, "")
85+
}
86+
87+
// Render ctrl bindings
88+
if len(ctrlBindings) > 0 {
89+
lines = append(lines,
90+
styles.DialogHelpStyle.Bold(true).Render("Control Key Shortcuts"),
91+
"",
92+
)
93+
for _, binding := range ctrlBindings {
94+
lines = append(lines, d.formatBinding(binding, keyStyle, descStyle))
95+
}
96+
lines = append(lines, "")
97+
}
98+
99+
// Render other bindings
100+
if len(otherBindings) > 0 {
101+
lines = append(lines,
102+
styles.DialogHelpStyle.Bold(true).Render("Other"),
103+
"",
104+
)
105+
for _, binding := range otherBindings {
106+
lines = append(lines, d.formatBinding(binding, keyStyle, descStyle))
107+
}
108+
}
109+
110+
return lines
111+
}
112+
113+
// formatBinding formats a single key binding as " key description"
114+
func (d *helpDialog) formatBinding(binding key.Binding, keyStyle, descStyle lipgloss.Style) string {
115+
helpInfo := binding.Help()
116+
helpKey := helpInfo.Key
117+
helpDesc := helpInfo.Desc
118+
119+
// Calculate spacing to align descriptions
120+
const keyWidth = 20
121+
const indent = 2
122+
123+
keyPart := keyStyle.Render(helpKey)
124+
descPart := descStyle.Render(helpDesc)
125+
126+
// Pad the key part to align descriptions
127+
keyPartWidth := lipgloss.Width(keyPart)
128+
padding := strings.Repeat(" ", max(1, keyWidth-keyPartWidth))
129+
130+
return fmt.Sprintf("%s%s%s%s",
131+
strings.Repeat(" ", indent),
132+
keyPart,
133+
padding,
134+
descPart,
135+
)
136+
}
137+
138+
func (d *helpDialog) Init() tea.Cmd {
139+
return d.readOnlyScrollDialog.Init()
140+
}
141+
142+
func (d *helpDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
143+
model, cmd := d.readOnlyScrollDialog.Update(msg)
144+
if rod, ok := model.(*readOnlyScrollDialog); ok {
145+
d.readOnlyScrollDialog = *rod
146+
}
147+
return d, cmd
148+
}
149+
150+
func (d *helpDialog) View() string {
151+
return d.readOnlyScrollDialog.View()
152+
}
153+
154+
func (d *helpDialog) Position() (int, int) {
155+
return d.readOnlyScrollDialog.Position()
156+
}
157+
158+
func (d *helpDialog) SetSize(width, height int) tea.Cmd {
159+
return d.readOnlyScrollDialog.SetSize(width, height)
160+
}
161+
162+
func (d *helpDialog) Bindings() []key.Binding {
163+
return []key.Binding{}
164+
}

pkg/tui/tui.go

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,8 +1553,8 @@ func (m *appModel) Help() help.KeyMap {
15531553
return core.NewSimpleHelp(m.Bindings())
15541554
}
15551555

1556-
// Bindings returns the key bindings shown in the status bar.
1557-
func (m *appModel) Bindings() []key.Binding {
1556+
// AllBindings returns ALL available key bindings for the help dialog (comprehensive list).
1557+
func (m *appModel) AllBindings() []key.Binding {
15581558
quitBinding := key.NewBinding(
15591559
key.WithKeys("ctrl+c"),
15601560
key.WithHelp("Ctrl+c", "quit"),
@@ -1572,10 +1572,48 @@ func (m *appModel) Bindings() []key.Binding {
15721572
bindings := []key.Binding{quitBinding, tabBinding}
15731573
bindings = append(bindings, m.tabBar.Bindings()...)
15741574

1575-
bindings = append(bindings, key.NewBinding(
1576-
key.WithKeys("ctrl+k"),
1577-
key.WithHelp("Ctrl+k", "commands"),
1578-
))
1575+
// Additional global shortcuts
1576+
bindings = append(bindings,
1577+
key.NewBinding(
1578+
key.WithKeys("ctrl+k"),
1579+
key.WithHelp("Ctrl+k", "commands"),
1580+
),
1581+
key.NewBinding(
1582+
key.WithKeys("ctrl+h"),
1583+
key.WithHelp("Ctrl+h", "help"),
1584+
),
1585+
key.NewBinding(
1586+
key.WithKeys("ctrl+y"),
1587+
key.WithHelp("Ctrl+y", "toggle yolo mode"),
1588+
),
1589+
key.NewBinding(
1590+
key.WithKeys("ctrl+o"),
1591+
key.WithHelp("Ctrl+o", "toggle hide tool results"),
1592+
),
1593+
key.NewBinding(
1594+
key.WithKeys("ctrl+s"),
1595+
key.WithHelp("Ctrl+s", "cycle agent"),
1596+
),
1597+
key.NewBinding(
1598+
key.WithKeys("ctrl+m"),
1599+
key.WithHelp("Ctrl+m", "model picker"),
1600+
),
1601+
key.NewBinding(
1602+
key.WithKeys("ctrl+x"),
1603+
key.WithHelp("Ctrl+x", "clear queue"),
1604+
),
1605+
key.NewBinding(
1606+
key.WithKeys("ctrl+z"),
1607+
key.WithHelp("Ctrl+z", "suspend"),
1608+
),
1609+
)
1610+
1611+
if !m.leanMode {
1612+
bindings = append(bindings, key.NewBinding(
1613+
key.WithKeys("ctrl+b"),
1614+
key.WithHelp("Ctrl+b", "toggle sidebar"),
1615+
))
1616+
}
15791617

15801618
// Show newline help based on keyboard enhancement support
15811619
if m.keyboardEnhancementsSupported {
@@ -1608,6 +1646,47 @@ func (m *appModel) Bindings() []key.Binding {
16081646
return bindings
16091647
}
16101648

1649+
// Bindings returns the key bindings shown in the status bar (a curated subset).
1650+
// This filters AllBindings() to show only the most essential commands.
1651+
func (m *appModel) Bindings() []key.Binding {
1652+
all := m.AllBindings()
1653+
1654+
// Define which keys should appear in the status bar
1655+
statusBarKeys := map[string]bool{
1656+
"ctrl+c": true, // quit
1657+
"tab": true, // switch focus
1658+
"ctrl+t": true, // new tab (from tabBar)
1659+
"ctrl+w": true, // close tab (from tabBar)
1660+
"ctrl+p": true, // prev tab (from tabBar)
1661+
"ctrl+n": true, // next tab (from tabBar)
1662+
"ctrl+k": true, // commands
1663+
"ctrl+h": true, // help
1664+
"shift+enter": true, // newline
1665+
"ctrl+j": true, // newline fallback
1666+
"ctrl+g": true, // edit in external editor (editor context)
1667+
"ctrl+r": true, // history search (editor context)
1668+
// Content panel bindings (↑↓, c, e, d) are always included
1669+
"up": true,
1670+
"down": true,
1671+
"c": true,
1672+
"e": true,
1673+
"d": true,
1674+
}
1675+
1676+
// Filter to only include status bar keys
1677+
var filtered []key.Binding
1678+
for _, binding := range all {
1679+
if len(binding.Keys()) > 0 {
1680+
bindingKey := binding.Keys()[0]
1681+
if statusBarKeys[bindingKey] {
1682+
filtered = append(filtered, binding)
1683+
}
1684+
}
1685+
}
1686+
1687+
return filtered
1688+
}
1689+
16111690
// handleKeyPress handles all keyboard input with proper priority routing.
16121691
func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
16131692
// 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) {
16871766

16881767
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+x"))):
16891768
return m, core.CmdHandler(messages.ClearQueueMsg{})
1769+
1770+
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+h", "f1", "ctrl+?"))):
1771+
// Show contextual help dialog with ALL available key bindings
1772+
return m, core.CmdHandler(dialog.OpenDialogMsg{
1773+
Model: dialog.NewHelpDialog(m.AllBindings()),
1774+
})
16901775
}
16911776

16921777
// History search is a modal state — capture all remaining keys before normal routing

0 commit comments

Comments
 (0)