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

feat: surround selection by typing markdown punctuation (TASK-009)

466af178186f08d6be8ddc647f44323073a77693
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 16:11

parent 4254a57c

feat: surround selection by typing markdown punctuation (TASK-009)

With a selection, typing * _ ` [ ( { < " ' or ~ now wraps the selection in
that pair instead of replacing it, keeping the selection so repeats nest
(* then * → **). Complements the Alt+s/i/c/k bindings. Documented in README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

4 files changed

README.md +1 −0
@@ -52,6 +52,7 @@ | `Home` / `End` | start / end of line |
 | `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
 | `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard) |
 | `Alt+s` / `Alt+i` / `Alt+c` / `Alt+k` | wrap the selection: **bold** `**` · _italic_ `_` · `code` `` ` `` · link `[sel]()` (toggles off if already wrapped; no selection inserts the empty pair) |
+| type `*` `_` `` ` `` `[` `(` `{` `<` `"` `'` with a selection | surround the selection with that punctuation (repeat to nest, e.g. `*`→`**` for bold) |
 | `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
 | `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
 | `Ctrl+U` / `Ctrl+K` | delete to start / end of line |
internal/editor/editor.go +8 −0
@@ -489,6 +489,14 @@ 				e.WrapLink()
 			}
 			return
 		}
+		// With a selection, typing a wrap punctuation surrounds it (nests on
+		// repeat) instead of replacing it.
+		if e.HasSelection() && len(k.Runes) == 1 {
+			if rc, ok := wrapPair(k.Runes[0]); ok {
+				e.surroundSelection(string(k.Runes[0]), string(rc))
+				return
+			}
+		}
 		e.replaceSelection()
 		for _, r := range k.Runes {
 			e.InsertRune(r)
internal/editor/format.go +37 −0
@@ -89,6 +89,43 @@ 	e.setGoal()
 	e.followCursor()
 }
 
+// wrapPair maps an opening punctuation rune to its closing rune for
+// surround-on-type: with a selection, typing one of these wraps the selection
+// instead of replacing it (repeated presses nest, e.g. * then * → **).
+func wrapPair(r rune) (rune, bool) {
+	switch r {
+	case '*', '_', '`', '"', '\'', '~':
+		return r, true
+	case '(':
+		return ')', true
+	case '[':
+		return ']', true
+	case '{':
+		return '}', true
+	case '<':
+		return '>', true
+	}
+	return 0, false
+}
+
+// surroundSelection wraps the active selection in left/right, leaving the
+// selection on the inner text so repeated wraps nest.
+func (e *Editor) surroundSelection(left, right string) {
+	start, end, ok := e.selRange()
+	if !ok {
+		return
+	}
+	e.insertAt(end, right)
+	e.insertAt(start, left)
+	nL := len([]rune(left))
+	ns := Position{Row: start.Row, Col: start.Col + nL}
+	ne := end
+	if start.Row == end.Row {
+		ne = Position{Row: end.Row, Col: end.Col + nL}
+	}
+	e.setSelection(ns, ne)
+}
+
 // insertPair inserts left+right at the cursor and parks the cursor `offset`
 // runes past the original position (i.e. between the two halves).
 func (e *Editor) insertPair(left, right string, offset int) {
internal/editor/format_test.go +58 −0
@@ -149,3 +149,61 @@ 	if _, _, ok := e.SelectionStats(); ok {
 		t.Fatal("SelectionStats ok should be false with no selection")
 	}
 }
+
+func TestTypingStarWrapsSelection(t *testing.T) {
+	e := newEditorWith("word")
+	sel(e, 0, 0, 0, 4)
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+	if e.Lines[0] != "*word*" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "*word*")
+	}
+	if e.SelectedText() != "word" {
+		t.Fatalf("selection = %q, want inner %q", e.SelectedText(), "word")
+	}
+}
+
+func TestTypingStarTwiceNestsToBold(t *testing.T) {
+	e := newEditorWith("word")
+	sel(e, 0, 0, 0, 4)
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+	if e.Lines[0] != "**word**" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "**word**")
+	}
+}
+
+func TestTypingBracketWrapsSelection(t *testing.T) {
+	e := newEditorWith("word")
+	sel(e, 0, 0, 0, 4)
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[")})
+	if e.Lines[0] != "[word]" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "[word]")
+	}
+}
+
+func TestTypingParenWrapsSelection(t *testing.T) {
+	e := newEditorWith("word")
+	sel(e, 0, 0, 0, 4)
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("(")})
+	if e.Lines[0] != "(word)" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "(word)")
+	}
+}
+
+func TestTypingWrapCharNoSelectionInsertsNormally(t *testing.T) {
+	e := newEditorWith("ab")
+	e.Cursor = Position{Row: 0, Col: 1}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("*")})
+	if e.Lines[0] != "a*b" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "a*b")
+	}
+}
+
+func TestTypingNonWrapCharReplacesSelection(t *testing.T) {
+	e := newEditorWith("word")
+	sel(e, 0, 0, 0, 4)
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
+	if e.Lines[0] != "x" {
+		t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "x")
+	}
+}