feat: in-editor help overlay (Ctrl+/) from shared source (TASK-011)
1dc94dd2de6d20ea26f87f2deb43442d993f2574
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 16:36
parent f744adce
feat: in-editor help overlay (Ctrl+/) from shared source (TASK-011) Extract the keybind/command reference out of main.go into a new internal/help package (help.Text) so the CLI (glint -h) and the editor overlay share one source of truth. Add a ModeHelp mode backed by a scrollable viewport: Ctrl+/ (tea.KeyCtrlUnderscore) toggles it, Ctrl+/ again or Esc closes. The overlay is a centered, theme-bordered box with a title and a close/scroll footer. The status bar help hint now reads '? ctrl+/', and the README keys table lists Ctrl+/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
6 files changed
README.md +1 −0
@@ -65,6 +65,7 @@ | `Ctrl+D` | today's daily note |
| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
| `Ctrl+B` | new note in the inbox |
| `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm) |
+| `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
| `Ctrl+Q` | quit (press twice if there are unsaved changes) |
| `Esc` | clear the selection, or close find / back to the editor |
- → In-app-help-overlay.md +19 −4
@@ -1,10 +1,10 @@
---
id: TASK-011
title: In-app help overlay
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 23:36'
labels:
- feature
- release-1
@@ -21,6 +21,21 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 A key toggles a help overlay listing editor keys + commands
-- [ ] #2 Esc closes it; content matches the CLI help
+- [x] #1 A key toggles a help overlay listing editor keys + commands
+- [x] #2 Esc closes it; content matches the CLI help
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. Extract main.go helpText into new internal/help package (exported help.Text); main.go consumes it (single source of truth).
+2. App: add ModeHelp + a viewport field; Ctrl+/ (tea.KeyCtrlUnderscore) toggles open/closed; Esc closes; scroll keys forwarded to the viewport while in help mode.
+3. View: render help.Text in a centered, theme-bordered overlay over the canvas; keep the status-bar '?' hint.
+4. TDD: toggle opens ModeHelp, Ctrl+/ again and Esc both close, overlay content == help.Text.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Extracted helpText into internal/help (help.Text) — single source for glint -h and the overlay. Added ModeHelp + scrollable viewport; Ctrl+/ (tea.KeyCtrlUnderscore) toggles, Ctrl+/ again or Esc closes. Centered themed bordered overlay; status hint now '? ctrl+/'. Added internal/help to README keys table. TDD: 5 tests in help_test.go, full suite + vet green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +55 −3
@@ -12,12 +12,14 @@ "time"
"glint/internal/config"
"glint/internal/editor"
+ "glint/internal/help"
"glint/internal/picker"
"glint/internal/preview"
"glint/internal/theme"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/textinput"
+ "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -31,6 +33,7 @@ ModePicker
ModePreview
ModeSaveAs
ModeFind
+ ModeHelp
)
// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -62,6 +65,7 @@ preview *preview.Model
picker *picker.Model
saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
findInput textinput.Model // one-line in-document find prompt (TASK-007)
+ helpView viewport.Model // scrollable keybind overlay (TASK-011)
path string
pickerRoot string // directory the current picker is browsing
saveDir string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -84,6 +88,8 @@ ti.Placeholder = "name…"
fi := textinput.New()
fi.Prompt = "find › "
fi.Placeholder = "text…"
+ hv := viewport.New(0, 0)
+ hv.SetContent(help.Text)
a := &App{
mode: ModeEditor,
cfg: cfg,
@@ -91,6 +97,7 @@ theme: th,
editor: ed,
saveInput: ti,
findInput: fi,
+ helpView: hv,
}
a.preview = preview.New(a.glamourStyle())
a.preview.SetColors(previewColors(th))
@@ -259,6 +266,8 @@ case tea.KeyCtrlV:
return a.paste()
case tea.KeyCtrlG:
return a.openFind()
+ case tea.KeyCtrlUnderscore: // Ctrl+/ toggles the help overlay
+ return a.toggleHelp()
case tea.KeyEsc:
if a.mode == ModeFind {
a.editor.ClearFind()
@@ -281,6 +290,10 @@ a.saveInput, cmd = a.saveInput.Update(msg)
return a, cmd
case ModeFind:
return a.handleFindKey(msg)
+ case ModeHelp:
+ var cmd tea.Cmd
+ a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll
+ return a, cmd
case ModePreview:
return a, a.preview.Update(msg)
case ModePicker:
@@ -548,6 +561,19 @@ // the filename on the left with a transient "Theme: …" message.
return a, nil
}
+// toggleHelp opens or closes the keybind overlay, sourced from help.Text (the
+// same reference printed by `glint -h`). It scrolls back to the top each time it
+// opens so the overlay always starts at the header.
+func (a *App) toggleHelp() (tea.Model, tea.Cmd) {
+ if a.mode == ModeHelp {
+ a.mode = ModeEditor
+ return a, nil
+ }
+ a.helpView.GotoTop()
+ a.mode = ModeHelp
+ return a, nil
+}
+
// togglePreview switches between the editor and the Glamour read view.
func (a *App) togglePreview() (tea.Model, tea.Cmd) {
if a.mode == ModePreview {
@@ -599,6 +625,11 @@ textRows = 1
}
a.editor.SetSize(cw, textRows)
a.preview.SetSize(cw, textRows)
+ // The help overlay is a bordered box on the canvas: fit it inside the column
+ // (minus the 1-cell border each side) and the text rows (minus border + a
+ // title/footer line each).
+ a.helpView.Width = maxInt(cw-2, 1)
+ a.helpView.Height = maxInt(textRows-4, 1)
if a.picker != nil {
a.picker.SetSize(w, h-1) // picker keeps its full-width split
}
@@ -663,9 +694,12 @@ return body + a.statusBar()
}
var body string
- if a.mode == ModePreview {
+ switch a.mode {
+ case ModeHelp:
+ body = a.helpOverlay()
+ case ModePreview:
body = a.preview.View()
- } else {
+ default:
body = a.editor.View() // editor stays visible beneath the save-as prompt
}
bottom := a.statusBar()
@@ -678,6 +712,24 @@ }
return a.paintCanvas(body) + bottom
}
+// helpOverlay renders the keybind reference (help.Text) as a centered, themed
+// bordered box on the canvas, with a title and a close/scroll footer.
+func (a *App) helpOverlay() string {
+ title := lipgloss.NewStyle().Foreground(a.theme.Heading).Bold(true).
+ Render("glint — keys & commands")
+ footer := lipgloss.NewStyle().Foreground(a.theme.Muted).
+ Render("Ctrl+/ or Esc to close · ↑/↓ to scroll")
+ inner := title + "\n\n" + a.helpView.View() + "\n\n" + footer
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(a.theme.Heading).
+ BorderBackground(a.theme.Background).
+ Background(a.theme.Background).
+ Foreground(a.theme.Text).
+ Width(maxInt(a.contentWidth()-2, 1))
+ return box.Render(inner)
+}
+
// findBar renders the find prompt as a themed full-width bottom bar, with the
// match count trailing.
func (a *App) findBar() string {
@@ -747,7 +799,7 @@ }
segs = append(segs,
fmt.Sprintf("%d words", a.editor.WordCount()),
a.theme.Name,
- "?",
+ "? ctrl+/",
)
return bar.Render(layoutStatus(" "+left, strings.Join(segs, " · ")+" ", maxInt(a.width, 1)))
}
internal/app/help_test.go +57 −0
@@ -0,0 +1,57 @@
+package app
+
+import (
+ "strings"
+ "testing"
+
+ "glint/internal/help"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// ctrlSlash is what the terminal sends for Ctrl+/ (the unit separator, 0x1f).
+var ctrlSlash = tea.KeyMsg{Type: tea.KeyCtrlUnderscore}
+
+func TestHelpToggleOpensAndCloses(t *testing.T) {
+ a := statusApp()
+ a.Update(ctrlSlash)
+ if a.mode != ModeHelp {
+ t.Fatalf("Ctrl+/ did not open help: mode = %v", a.mode)
+ }
+ a.Update(ctrlSlash)
+ if a.mode != ModeEditor {
+ t.Fatalf("second Ctrl+/ did not close help: mode = %v", a.mode)
+ }
+}
+
+func TestHelpEscCloses(t *testing.T) {
+ a := statusApp()
+ a.Update(ctrlSlash)
+ a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if a.mode != ModeEditor {
+ t.Fatalf("Esc did not close help: mode = %v", a.mode)
+ }
+}
+
+func TestHelpViewShowsHelpContent(t *testing.T) {
+ a := statusApp()
+ a.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+ a.Update(ctrlSlash)
+ got := a.View()
+ if !strings.Contains(got, "USAGE") {
+ t.Fatalf("help view missing content from help.Text:\n%s", got)
+ }
+}
+
+func TestHelpUsesSharedSource(t *testing.T) {
+ if !strings.Contains(help.Text, "EDITOR KEYS") || !strings.Contains(help.Text, "Ctrl+S") {
+ t.Fatalf("help.Text is not the full reference:\n%s", help.Text)
+ }
+}
+
+func TestStatusBarShowsToggleKey(t *testing.T) {
+ a := statusApp()
+ if got := a.statusBar(); !strings.Contains(got, "ctrl+/") {
+ t.Fatalf("status bar missing help toggle key:\n%s", got)
+ }
+}
internal/help/help.go +49 −0
@@ -0,0 +1,49 @@
+// Package help holds glint's single source of truth for the keybind and command
+// reference, shared by the CLI (glint -h) and the in-editor help overlay.
+package help
+
+// Text is the full reference printed by `glint -h` and shown in the Ctrl+/
+// overlay inside the editor.
+const Text = `glint — a modeless terminal markdown editor
+
+USAGE
+ glint [file] open a file, or the fuzzy picker when no file is given
+ glint <command>
+
+COMMANDS (each takes a short letter or the full word, one or two dashes:
+ -n / --n / -new / --new all work)
+ -n, --new [name] new note in the current directory
+ combine with -i or -v to target the inbox or vault
+ -t, --today open today's daily note (in the vault)
+ -d, --daily browse the daily-notes folder
+ -v, --vault fuzzy picker over your vault, from anywhere
+ -i, --inbox fuzzy picker over your inbox
+ -c, --config interactive setup walkthrough (writes the config file)
+ -h, --help show this help
+ --version print the version
+
+EDITOR KEYS
+ Enter (on a list) continue the list marker (numbers increment, checkboxes
+ reset); Enter on an empty item exits the list
+ Tab / Shift+Tab indent / outdent the current list item
+ Ctrl+S save (an unnamed buffer prompts for a name)
+ Ctrl+P toggle the read preview
+ Ctrl+F fuzzy file picker
+ Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
+ Ctrl+D today's daily note
+ Ctrl+N new note in the current directory
+ Ctrl+B new note in the inbox
+ Ctrl+T cycle theme (flexoki-light / flexoki-dark / charm)
+ Ctrl+C / Ctrl+X / Ctrl+V copy / cut / paste (system clipboard)
+ Shift+arrows select text (Ctrl+Shift+left/right by word)
+ Alt+left / Alt+right move by word
+ Ctrl+U / Ctrl+K delete to start / end of line
+ Ctrl+W delete the word before the cursor
+ Ctrl+Z / Ctrl+Y undo / redo
+ Ctrl+/ toggle this help overlay
+ Ctrl+Q quit (press twice if there are unsaved changes)
+ Esc back to the editor
+
+CONFIG
+ ~/.config/glint/config.toml (run 'glint -c' to set it up)
+`
main.go +2 −44
@@ -9,6 +9,7 @@
"glint/internal/app"
"glint/internal/config"
"glint/internal/configui"
+ "glint/internal/help"
"glint/internal/keyprobe"
tea "github.com/charmbracelet/bubbletea"
@@ -26,56 +27,13 @@ // version is the build version, overridden at release time via
// -ldflags "-X main.version=<v>" (the Homebrew formula sets it).
var version = "dev"
-const helpText = `glint — a modeless terminal markdown editor
-
-USAGE
- glint [file] open a file, or the fuzzy picker when no file is given
- glint <command>
-
-COMMANDS (each takes a short letter or the full word, one or two dashes:
- -n / --n / -new / --new all work)
- -n, --new [name] new note in the current directory
- combine with -i or -v to target the inbox or vault
- -t, --today open today's daily note (in the vault)
- -d, --daily browse the daily-notes folder
- -v, --vault fuzzy picker over your vault, from anywhere
- -i, --inbox fuzzy picker over your inbox
- -c, --config interactive setup walkthrough (writes the config file)
- -h, --help show this help
- --version print the version
-
-EDITOR KEYS
- Enter (on a list) continue the list marker (numbers increment, checkboxes
- reset); Enter on an empty item exits the list
- Tab / Shift+Tab indent / outdent the current list item
- Ctrl+S save (an unnamed buffer prompts for a name)
- Ctrl+P toggle the read preview
- Ctrl+F fuzzy file picker
- Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
- Ctrl+D today's daily note
- Ctrl+N new note in the current directory
- Ctrl+B new note in the inbox
- Ctrl+T cycle theme (flexoki-light / flexoki-dark / charm)
- Ctrl+C / Ctrl+X / Ctrl+V copy / cut / paste (system clipboard)
- Shift+arrows select text (Ctrl+Shift+left/right by word)
- Alt+left / Alt+right move by word
- Ctrl+U / Ctrl+K delete to start / end of line
- Ctrl+W delete the word before the cursor
- Ctrl+Z / Ctrl+Y undo / redo
- Ctrl+Q quit (press twice if there are unsaved changes)
- Esc back to the editor
-
-CONFIG
- ~/.config/glint/config.toml (run 'glint -c' to set it up)
-`
-
func main() {
// Help and version short-circuit before flag parsing (-v means --vault, so
// version is long-only).
if len(os.Args) > 1 {
switch os.Args[1] {
case "-h", "--h", "-help", "--help":
- fmt.Print(helpText)
+ fmt.Print(help.Text)
return
case "--version", "-version":
fmt.Println("glint", version)