▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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)