Skip to content

Commit c84668a

Browse files
committed
keybinding revamp and help system
- Extract all keybindings to named constants (keyTab, keyEnter, keyEsc, etc.) - Implement comprehensive help screen accessible via ? or F1 - Add handleHelpInput() to process help mode keyboard navigation - Add renderHelp() to generate detailed keybinding documentation - Make footer hints adaptive to terminal width with footerHints() - Add modeHelp state and help mode input handling - Update import order (alphabetical) - Add tests for help system, adaptive footer, and keybinding triggers - Update README with ? keybinding documentation
1 parent 080620c commit c84668a

6 files changed

Lines changed: 365 additions & 50 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ lazygaze --model opus HEAD~5..HEAD
9898
| Key | Action |
9999
|-----|--------|
100100
| `tab` | Switch focus between diff and review panes |
101+
| `?` | Open full keybinding help screen |
101102
| `j` / `k` | Scroll focused pane |
102103
| `q` / `ctrl+c` | Quit |
103104

internal/tui/handlers.go

Lines changed: 105 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"strconv"
77
"strings"
88

9-
tea "github.com/charmbracelet/bubbletea"
109
"github.com/charmbracelet/bubbles/viewport"
10+
tea "github.com/charmbracelet/bubbletea"
1111
"github.com/charmbracelet/lipgloss"
1212
)
1313

@@ -17,7 +17,7 @@ import (
1717
// When a valid range is submitted, it transitions the model to fetch and display the diff.
1818
func (m Model) handleGitRangeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1919
switch msg.String() {
20-
case "enter":
20+
case keyEnter:
2121
val := strings.TrimSpace(m.gitRangeInput.Value())
2222
m.mode = modeNormal
2323
m.gitRangeInput.Blur()
@@ -28,7 +28,7 @@ func (m Model) handleGitRangeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
2828
m, ctx = resetForNewStream(m)
2929
m.diffSrc = diffSourceRange
3030
return m, fetchDiffCmd(ctx, val, m.diffFetchGen)
31-
case "esc":
31+
case keyEsc:
3232
m.mode = modeNormal
3333
m.gitRangeInput.Blur()
3434
return m, nil
@@ -46,7 +46,7 @@ func (m Model) handleGitRangeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
4646
// If the prompt is empty or no diff content is loaded, an error is returned.
4747
func (m Model) handlePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
4848
switch msg.String() {
49-
case "enter":
49+
case keyEnter:
5050
val := strings.TrimSpace(m.promptInput.Value())
5151
m.mode = modeNormal
5252
m.promptInput.Blur()
@@ -61,7 +61,7 @@ func (m Model) handlePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
6161
m.promptNoPersona = false
6262
m = resetForNewReview(m)
6363
return m, startStreamCmd(m.activeHarness, m.buildFullPrompt(), m.diffContent)
64-
case "esc":
64+
case keyEsc:
6565
m.mode = modeNormal
6666
m.promptInput.Blur()
6767
return m, nil
@@ -84,7 +84,7 @@ func (m Model) handlePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8484
// Returns the updated model and any resulting command.
8585
func (m Model) handleTildeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8686
switch msg.String() {
87-
case "enter":
87+
case keyEnter:
8888
val := strings.TrimSpace(m.tildeInput.Value())
8989
m.mode = modeNormal
9090
m.tildeInput.Blur()
@@ -101,7 +101,7 @@ func (m Model) handleTildeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
101101
m, ctx = resetForNewStream(m)
102102
m.diffSrc = diffSourceRange
103103
return m, fetchDiffCmd(ctx, m.gitRange, m.diffFetchGen)
104-
case "esc":
104+
case keyEsc:
105105
m.mode = modeNormal
106106
m.tildeInput.Blur()
107107
return m, nil
@@ -119,24 +119,24 @@ func (m Model) handleTildeInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
119119
// to normal mode.
120120
func (m Model) handleLibraryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
121121
switch msg.String() {
122-
case "enter":
122+
case keyEnter:
123123
entry := PromptLibrary[m.libraryIndex]
124124
m.prompt = entry.Prompt
125125
m.promptNoPersona = entry.NoPersona
126126
m.mode = modeNormal
127127
m = resetForNewReview(m)
128128
return m, startStreamCmd(m.activeHarness, m.buildFullPrompt(), m.diffContent)
129-
case "esc":
129+
case keyEsc:
130130
m.mode = modeNormal
131131
m.reviewViewport.SetContent(m.renderMarkdown(m.reviewContent.String()))
132132
return m, nil
133-
case "j", "down":
133+
case keyScrollDown, keyDown:
134134
if m.libraryIndex < len(PromptLibrary)-1 {
135135
m.libraryIndex++
136136
}
137137
m.reviewViewport.SetContent(m.renderLibraryList())
138138
return m, nil
139-
case "k", "up":
139+
case keyScrollUp, keyUp:
140140
if m.libraryIndex > 0 {
141141
m.libraryIndex--
142142
}
@@ -189,7 +189,7 @@ func (m Model) handlePersonaInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
189189
// Index 0 = "(None)", 1 = "(Critical Only)", 2 = "(Terse)", 3..len(Personas)+2 = personas
190190
maxIndex := len(Personas) + 2
191191
switch msg.String() {
192-
case "enter":
192+
case keyEnter:
193193
m.mode = modeNormal
194194
switch m.personaIndex {
195195
case 0:
@@ -208,19 +208,19 @@ func (m Model) handlePersonaInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
208208
}
209209
m = resetForNewReview(m)
210210
return m, tea.Batch(saveCmd, startStreamCmd(m.activeHarness, m.buildFullPrompt(), m.diffContent))
211-
case "esc":
211+
case keyEsc:
212212
m.mode = modeNormal
213213
m.reviewViewport.SetContent(m.renderMarkdown(m.reviewContent.String()))
214214
return m, nil
215-
case "j", "down":
215+
case keyScrollDown, keyDown:
216216
if m.personaIndex < maxIndex {
217217
m.personaIndex++
218218
}
219219
content, sel := m.renderPersonaList()
220220
m.reviewViewport.SetContent(content)
221221
scrollPersonaViewport(&m.reviewViewport, sel)
222222
return m, nil
223-
case "k", "up":
223+
case keyScrollUp, keyUp:
224224
if m.personaIndex > 0 {
225225
m.personaIndex--
226226
}
@@ -299,12 +299,12 @@ func scrollPersonaViewport(vp *viewport.Model, selectedLine int) {
299299
// state, returning to normal mode.
300300
func (m Model) handleConfirmLargeDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
301301
switch msg.String() {
302-
case "enter":
302+
case keyEnter:
303303
m.pendingDiff = nil
304304
m.mode = modeNormal
305305
m = resetForNewReview(m)
306306
return m, startStreamCmd(m.activeHarness, m.buildFullPrompt(), m.diffContent)
307-
case "esc":
307+
case keyEsc:
308308
m.pendingDiff = nil
309309
m.mode = modeNormal
310310
m.diffContent = ""
@@ -319,25 +319,47 @@ func (m Model) handleConfirmLargeDiff(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
319319
}
320320
}
321321

322+
// handleHelpInput processes keyboard input while the help menu is visible. Esc,
323+
// q, Ctrl-C, Help, Atl-Help F1 or ? closes the overlay and restores the current
324+
// review content. j/k and up/down are passed through so users can scroll long
325+
// help pages.
326+
func (m Model) handleHelpInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
327+
switch msg.String() {
328+
case keyEsc, keyQuit, keyHelp, keyHelpAlt, keyCtrlC, keyF1:
329+
m.mode = modeNormal
330+
m.reviewViewport.SetContent(m.renderMarkdown(m.reviewContent.String()))
331+
if m.autoScroll {
332+
m.reviewViewport.GotoBottom()
333+
}
334+
return m, nil
335+
case keyScrollDown, keyDown, keyScrollUp, keyUp:
336+
var cmd tea.Cmd
337+
m.reviewViewport, cmd = m.reviewViewport.Update(msg)
338+
return m, cmd
339+
default:
340+
return m, nil
341+
}
342+
}
343+
322344
// handleHarnessInput processes keyboard input when the TUI is in harness selection mode.
323345
func (m Model) handleHarnessInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
324346
switch msg.String() {
325-
case "enter":
347+
case keyEnter:
326348
m.activeHarness = m.availableHarnesses[m.harnessIndex].WithModel(m.modelName)
327349
m.mode = modeNormal
328350
m.reviewViewport.SetContent(m.renderMarkdown(m.reviewContent.String()))
329351
return m, saveProfileCmd(m)
330-
case "esc":
352+
case keyEsc:
331353
m.mode = modeNormal
332354
m.reviewViewport.SetContent(m.renderMarkdown(m.reviewContent.String()))
333355
return m, nil
334-
case "j", "down":
356+
case keyScrollDown, keyDown:
335357
if m.harnessIndex < len(m.availableHarnesses)-1 {
336358
m.harnessIndex++
337359
}
338360
m.reviewViewport.SetContent(m.renderHarnessList())
339361
return m, nil
340-
case "k", "up":
362+
case keyScrollUp, keyUp:
341363
if m.harnessIndex > 0 {
342364
m.harnessIndex--
343365
}
@@ -365,3 +387,65 @@ func (m Model) renderHarnessList() string {
365387
}
366388
return b.String()
367389
}
390+
391+
// renderHelp builds the full keybinding help text shown in help mode.
392+
func (m Model) renderHelp() string {
393+
var b strings.Builder
394+
headerStyle := lipgloss.NewStyle().
395+
Bold(true).
396+
Foreground(lipgloss.Color("229"))
397+
sectionStyle := lipgloss.NewStyle().
398+
Bold(true).
399+
Foreground(lipgloss.Color("63"))
400+
noteStyle := lipgloss.NewStyle().
401+
Foreground(lipgloss.Color("240"))
402+
403+
b.WriteString(headerStyle.Render("Keyboard Shortcuts"))
404+
b.WriteString("\n")
405+
b.WriteString(noteStyle.Render(fmt.Sprintf("Press [%s], [%s], [%s], %s, or [%s] to close", keyEsc, keyQuit, keyCtrlC, keyHelp, keyF1)))
406+
b.WriteString("\n\n")
407+
408+
b.WriteString(sectionStyle.Render("Normal Mode") + "\n")
409+
b.WriteString(fmt.Sprintf("[%s] switch focused pane\n", keyTab))
410+
b.WriteString(fmt.Sprintf("[%s/%s/%s/%s] scroll focused pane\n", keyScrollDown, keyScrollUp, keyDown, keyUp))
411+
b.WriteString(fmt.Sprintf("[%s] zoom in/out the active pane\n", keyZoom))
412+
b.WriteString(fmt.Sprintf("[%s/%s] open this cheat sheet\n", keyHelp, keyF1))
413+
b.WriteString(fmt.Sprintf("[%s] / [%s] quit app\n", keyQuit, keyCtrlC))
414+
b.WriteString("\n")
415+
416+
b.WriteString(sectionStyle.Render("Review Actions") + "\n")
417+
b.WriteString(fmt.Sprintf("[%s] set a custom prompt\n", keyPrompt))
418+
b.WriteString(fmt.Sprintf("[%s] open prompt library\n", keyLibrary))
419+
b.WriteString(fmt.Sprintf("[%s] choose review persona\n", keyPersona))
420+
b.WriteString(fmt.Sprintf("[%s] cycle review model\n", keyModel))
421+
b.WriteString(fmt.Sprintf("[%s] copy focused pane\n", keyCopy))
422+
if len(m.availableHarnesses) > 1 {
423+
b.WriteString(fmt.Sprintf("[%s] choose review harness\n", keyHarness))
424+
}
425+
b.WriteString("\n")
426+
427+
b.WriteString(sectionStyle.Render("Diff Source") + "\n")
428+
b.WriteString(fmt.Sprintf("[%s] set an arbitrary git range\n", keyGitRange))
429+
b.WriteString(fmt.Sprintf("[%s] open HEAD~n..HEAD picker\n", keyTilde))
430+
b.WriteString(fmt.Sprintf("[%s] review last commit\n", keyLastCommit))
431+
b.WriteString(fmt.Sprintf("[%s] review staged changes\n", keyStaged))
432+
b.WriteString(fmt.Sprintf("[%s] review uncommitted changes\n", keyDirty))
433+
b.WriteString(fmt.Sprintf("[%s] review upstream diff\n", keyUpstream))
434+
b.WriteString(fmt.Sprintf("[%s] refresh current diff\n", keyRefresh))
435+
b.WriteString("\n")
436+
437+
b.WriteString(sectionStyle.Render("Input Modes") + "\n")
438+
b.WriteString(fmt.Sprintf("git range (%s): [%s] fetch | [%s] cancel\n", keyGitRange, keyEnter, keyEsc))
439+
b.WriteString(fmt.Sprintf("prompt (%s): [%s] run review | [%s] cancel\n", keyPrompt, keyEnter, keyEsc))
440+
b.WriteString(fmt.Sprintf("HEAD~n (%s): [%s] fetch | [%s] cancel\n", keyTilde, keyEnter, keyEsc))
441+
b.WriteString(fmt.Sprintf("library/persona/harness: [%s/%s/%s/%s] navigate | [%s] select | [%s] cancel\n", keyScrollDown, keyScrollUp, keyDown, keyUp, keyEnter, keyEsc))
442+
b.WriteString(fmt.Sprintf("large diff confirm: [%s] continue | [%s] cancel\n", keyEnter, keyEsc))
443+
b.WriteString(fmt.Sprintf("help mode: [%s/%s/%s/%s] scroll | [%s/%s/%s/%s/%s] close\n", keyScrollDown, keyScrollUp, keyDown, keyUp, keyEsc, keyQuit, keyCtrlC, keyHelp, keyF1))
444+
b.WriteString("\n")
445+
446+
b.WriteString(sectionStyle.Render("Notes") + "\n")
447+
b.WriteString(noteStyle.Render(fmt.Sprintf("If only one harness is configured, [%s] will not be shown.", keyHarness)))
448+
b.WriteString("\n")
449+
450+
return b.String()
451+
}

0 commit comments

Comments
 (0)