Skip to content

Commit 19edfc3

Browse files
committed
add configurable select-all for composer text
1 parent be930d1 commit 19edfc3

11 files changed

Lines changed: 364 additions & 8 deletions

File tree

‎PLAN.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Current Stage
44

5+
### Composer quality of life
6+
7+
- [x] Configurable `Ctrl+A` selects all inline composer text, including wrapped/off-screen lines, with theme-aware highlighting and a selection notice.
8+
- [x] Typing, text paste, and newlines replace selection; Backspace/Delete clear it; Escape deselects before leaving insert mode. Replies/attachments are unaffected, mention metadata is discarded only on replacement, and Enter retains normal send behavior.
9+
- [x] Selection remains transient across chat/draft restores, sends, and external editor results. Regression tests cover Unicode, draft capture, editing isolation, remapped keys, mention autocomplete, blocked sends, and narrow rendering.
10+
11+
Verified on Linux on 2026-09-24: `make test`, `make lint`, and full
12+
`make test-race`. `TestComposerSelectionHighlightAndWrapping` also provides
13+
a synthetic terminal capture with `go test ./internal/ui -run '^TestComposerSelectionHighlightAndWrapping$' -v`.
14+
515
### Startup sync correction
616

717
- [x] Import contact app-state events and resolve saved names regardless of chat/contact event order, including known identity aliases and concurrent imports.

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ key_normal_unload_previews = "leader h f"
176176
key_normal_delete_for_everybody = "leader d e"
177177

178178
key_insert_send = "enter"
179+
key_insert_select_all = "ctrl+a"
180+
key_insert_delete = "delete"
179181
key_insert_newline = "ctrl+j"
180182
key_insert_newline_alt = "shift+enter"
181183
key_insert_attach = "ctrl+f"
@@ -231,7 +233,9 @@ Useful command-mode actions:
231233
:quit
232234
```
233235

234-
Text clipboard paste uses `key_normal_paste_text` or `:paste`. It appends clipboard text to the active chat draft, switches to insert mode, persists the draft, and does not send until the normal composer send key is pressed. Set `clipboard_paste_command` to override text clipboard reads.
236+
In insert mode, `Ctrl+A` selects the entire composer text, including wrapped and off-screen lines. Typing or terminal text paste replaces the selection; Backspace or Delete clears it. Escape deselects first; a second Escape leaves insert mode as usual. Attachments and reply context are not selected. Enter still sends the text. Configure these actions with `key_insert_select_all` and `key_insert_delete`; Delete without a selection does nothing because the inline caret is at the end. The help overlay shows your configured keys.
237+
238+
Text clipboard paste uses `key_normal_paste_text` or `:paste`. It appends clipboard text to the active chat draft (or replaces an active text selection), switches to insert mode, persists the draft, and does not send until the normal composer send key is pressed. Set `clipboard_paste_command` to override text clipboard reads.
235239

236240
Image clipboard paste/copy is image-only. `key_insert_paste_image` stages the current clipboard image as the composer attachment, preserving composer text as the caption. `key_normal_copy_image` copies the focused image message to the clipboard and auto-downloads remote image media first when possible. The default image clipboard commands auto-detect Wayland/X11 tools; set `clipboard_image_paste_command` or `clipboard_image_copy_command` to override them. Paste commands may write to `{path}` or stdout, and copy commands may use `{path}` and `{mime}` or receive image bytes on stdin.
237241

‎config.example.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,16 @@ key_normal_save_media = "leader s"
8383
key_normal_unload_previews = "leader h f"
8484
key_normal_delete_for_everybody = "leader d e"
8585

86-
# Insert mode
86+
# Insert mode (selected text: typing replaces, Backspace/Delete clears, cancel deselects)
8787
key_insert_attach = "ctrl+f"
8888
key_insert_paste_image = "ctrl+v"
8989
key_insert_remove_attachment = "ctrl+x"
9090
key_insert_newline = "ctrl+j"
9191
key_insert_newline_alt = "shift+enter"
9292
key_insert_cancel = "esc"
9393
key_insert_send = "enter"
94+
key_insert_select_all = "ctrl+a"
95+
key_insert_delete = "delete"
9496
key_insert_backspace = "backspace"
9597
key_insert_backspace_alt = "ctrl+h"
9698
key_insert_mention_select_alt = "tab"

‎internal/config/config_test.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ func TestLoadDefaultsWhenConfigMissing(t *testing.T) {
6262
if cfg.Keymap.NormalReact != "leader r" || cfg.Keymap.ReactionCustom != "enter" || cfg.Keymap.ReactionClear != "0" || cfg.Keymap.ReactionSelect1 != "1" || cfg.Keymap.ReactionSelect9 != "9" {
6363
t.Fatalf("reaction keymap defaults = normal %q custom %q clear %q select1 %q select9 %q", cfg.Keymap.NormalReact, cfg.Keymap.ReactionCustom, cfg.Keymap.ReactionClear, cfg.Keymap.ReactionSelect1, cfg.Keymap.ReactionSelect9)
6464
}
65+
if cfg.Keymap.InsertSelectAll != "ctrl+a" || cfg.Keymap.InsertDelete != "delete" {
66+
t.Fatalf("composer selection defaults = %q/%q", cfg.Keymap.InsertSelectAll, cfg.Keymap.InsertDelete)
67+
}
6568
if cfg.PreviewMaxWidth != 67 || cfg.PreviewMaxHeight != 18 {
6669
t.Fatalf("preview defaults = %dx%d, want 67x18", cfg.PreviewMaxWidth, cfg.PreviewMaxHeight)
6770
}
@@ -114,6 +117,8 @@ func TestLoadParsesSupportedKeys(t *testing.T) {
114117
`key_insert_mention_move_down = "j"`,
115118
`key_insert_mention_move_up = "k"`,
116119
`key_insert_send = "ctrl+s"`,
120+
`key_insert_select_all = "ctrl+e"`,
121+
`key_insert_delete = "ctrl+d"`,
117122
`key_visual_forward = "F"`,
118123
`key_visual_delete_for_everybody = "leader d e"`,
119124
`key_forward_search = "s"`,
@@ -191,6 +196,9 @@ func TestLoadParsesSupportedKeys(t *testing.T) {
191196
if cfg.Keymap.NormalQuit != "x" || cfg.Keymap.NormalOpenMediaDetached != "alt+enter" || cfg.Keymap.NormalYankMessage != "Y" || cfg.Keymap.NormalPasteText != "P" || cfg.Keymap.NormalEditMessage != "leader e" || cfg.Keymap.NormalComposeEditor != "leader z" || cfg.Keymap.NormalReact != "leader x" || cfg.Keymap.NormalForward != "leader f" || cfg.Keymap.NormalPickSticker != "leader t" || cfg.Keymap.NormalTogglePinned != "leader p" || cfg.Keymap.NormalToggleNotifications != "leader m" || cfg.Keymap.NormalCopyImage != "leader c" || cfg.Keymap.NormalSaveMedia != "leader y" || cfg.Keymap.InsertPasteImage != "ctrl+p" || cfg.Keymap.InsertMentionSelectAlt != "alt+enter" || cfg.Keymap.InsertMentionMoveDown != "j" || cfg.Keymap.InsertMentionMoveUp != "k" || cfg.Keymap.InsertSend != "ctrl+s" || cfg.Keymap.VisualForward != "F" || cfg.Keymap.VisualDeleteForEverybody != "leader d e" || cfg.Keymap.ForwardSearch != "s" || cfg.Keymap.ReactionSelect1 != "a" || cfg.Keymap.ReactionClear != "c" {
192197
t.Fatalf("keymap = quit %q detached-open %q yank %q paste-text %q edit %q compose-editor %q react %q normal-forward %q sticker %q pinned %q notifications %q copy-image %q save %q paste-image %q mention-select %q mention-down %q mention-up %q send %q visual-forward %q visual-delete %q forward-search %q reaction1 %q reaction-clear %q", cfg.Keymap.NormalQuit, cfg.Keymap.NormalOpenMediaDetached, cfg.Keymap.NormalYankMessage, cfg.Keymap.NormalPasteText, cfg.Keymap.NormalEditMessage, cfg.Keymap.NormalComposeEditor, cfg.Keymap.NormalReact, cfg.Keymap.NormalForward, cfg.Keymap.NormalPickSticker, cfg.Keymap.NormalTogglePinned, cfg.Keymap.NormalToggleNotifications, cfg.Keymap.NormalCopyImage, cfg.Keymap.NormalSaveMedia, cfg.Keymap.InsertPasteImage, cfg.Keymap.InsertMentionSelectAlt, cfg.Keymap.InsertMentionMoveDown, cfg.Keymap.InsertMentionMoveUp, cfg.Keymap.InsertSend, cfg.Keymap.VisualForward, cfg.Keymap.VisualDeleteForEverybody, cfg.Keymap.ForwardSearch, cfg.Keymap.ReactionSelect1, cfg.Keymap.ReactionClear)
193198
}
199+
if cfg.Keymap.InsertSelectAll != "ctrl+e" || cfg.Keymap.InsertDelete != "ctrl+d" {
200+
t.Fatalf("composer selection overrides = %q/%q", cfg.Keymap.InsertSelectAll, cfg.Keymap.InsertDelete)
201+
}
194202
if cfg.PreviewMaxWidth != 44 || cfg.PreviewMaxHeight != 10 || cfg.PreviewDelayMS != 0 {
195203
t.Fatalf("preview sizing = %dx%d delay=%d", cfg.PreviewMaxWidth, cfg.PreviewMaxHeight, cfg.PreviewDelayMS)
196204
}
@@ -523,6 +531,8 @@ func TestEnsureDefaultFileCreatesStandardConfig(t *testing.T) {
523531
`key_normal_unload_previews = "leader h f"`,
524532
`key_normal_delete_for_everybody = "leader d e"`,
525533
`key_insert_attach = "ctrl+f"`,
534+
`key_insert_select_all = "ctrl+a"`,
535+
`key_insert_delete = "delete"`,
526536
`key_insert_paste_image = "ctrl+v"`,
527537
`key_insert_newline_alt = "shift+enter"`,
528538
`key_insert_mention_select_alt = "tab"`,
@@ -631,6 +641,9 @@ func TestExampleConfigParses(t *testing.T) {
631641
if cfg.Keymap.InsertPasteImage != "ctrl+v" {
632642
t.Fatalf("InsertPasteImage = %q, want ctrl+v", cfg.Keymap.InsertPasteImage)
633643
}
644+
if cfg.Keymap.InsertSelectAll != "ctrl+a" || cfg.Keymap.InsertDelete != "delete" {
645+
t.Fatalf("example composer selection keys = %q/%q", cfg.Keymap.InsertSelectAll, cfg.Keymap.InsertDelete)
646+
}
634647
if cfg.Keymap.InsertNewlineAlt != "shift+enter" {
635648
t.Fatalf("InsertNewlineAlt = %q, want shift+enter", cfg.Keymap.InsertNewlineAlt)
636649
}

‎internal/config/default_file.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func DefaultFileContent(paths Paths) string {
8989
bindings := KeymapBindings(cfg.Keymap)
9090
writeKeyGroup(&b, "Sync screen (cached browsing does not enable sending)", keyBindingsForMode(bindings, KeyModeSync))
9191
writeKeyGroup(&b, "Normal mode", keyBindingsForMode(bindings, KeyModeNormal))
92-
writeKeyGroup(&b, "Insert mode", keyBindingsForMode(bindings, KeyModeInsert))
92+
writeKeyGroup(&b, "Insert mode (selected text: typing replaces, Backspace/Delete clears, cancel deselects)", keyBindingsForMode(bindings, KeyModeInsert))
9393
writeKeyGroup(&b, "Visual mode", keyBindingsForMode(bindings, KeyModeVisual))
9494
writeKeyGroup(&b, "Forward picker", keyBindingsForMode(bindings, KeyModeForward))
9595
writeKeyGroup(&b, "Reaction picker", keyBindingsForMode(bindings, KeyModeReaction))

‎internal/config/keymap.go‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ type Keymap struct {
7171
InsertNewlineAlt string
7272
InsertCancel string
7373
InsertSend string
74+
InsertSelectAll string
75+
InsertDelete string
7476
InsertBackspace string
7577
InsertBackspaceAlt string
7678
InsertMentionSelectAlt string
@@ -180,6 +182,8 @@ func DefaultKeymap() Keymap {
180182
InsertNewlineAlt: "shift+enter",
181183
InsertCancel: "esc",
182184
InsertSend: "enter",
185+
InsertSelectAll: "ctrl+a",
186+
InsertDelete: "delete",
183187
InsertBackspace: "backspace",
184188
InsertBackspaceAlt: "ctrl+h",
185189
InsertMentionSelectAlt: "tab",
@@ -376,6 +380,12 @@ func NormalizeKeymap(input Keymap) Keymap {
376380
if input.InsertSend == "" {
377381
input.InsertSend = defaults.InsertSend
378382
}
383+
if input.InsertSelectAll == "" {
384+
input.InsertSelectAll = defaults.InsertSelectAll
385+
}
386+
if input.InsertDelete == "" {
387+
input.InsertDelete = defaults.InsertDelete
388+
}
379389
if input.InsertBackspace == "" {
380390
input.InsertBackspace = defaults.InsertBackspace
381391
}
@@ -561,6 +571,8 @@ func KeymapBindings(k Keymap) []KeyBinding {
561571
{Name: "key_insert_newline_alt", Mode: KeyModeInsert, Value: k.InsertNewlineAlt},
562572
{Name: "key_insert_cancel", Mode: KeyModeInsert, Value: k.InsertCancel},
563573
{Name: "key_insert_send", Mode: KeyModeInsert, Value: k.InsertSend},
574+
{Name: "key_insert_select_all", Mode: KeyModeInsert, Value: k.InsertSelectAll},
575+
{Name: "key_insert_delete", Mode: KeyModeInsert, Value: k.InsertDelete},
564576
{Name: "key_insert_backspace", Mode: KeyModeInsert, Value: k.InsertBackspace},
565577
{Name: "key_insert_backspace_alt", Mode: KeyModeInsert, Value: k.InsertBackspaceAlt},
566578
{Name: "key_insert_mention_select_alt", Mode: KeyModeInsert, Value: k.InsertMentionSelectAlt},
@@ -708,6 +720,10 @@ func SetKeyBinding(k *Keymap, name, value string) error {
708720
k.InsertCancel = normalized
709721
case "key_insert_send":
710722
k.InsertSend = normalized
723+
case "key_insert_select_all":
724+
k.InsertSelectAll = normalized
725+
case "key_insert_delete":
726+
k.InsertDelete = normalized
711727
case "key_insert_backspace":
712728
k.InsertBackspace = normalized
713729
case "key_insert_backspace_alt":
@@ -847,7 +863,7 @@ func ParseKeyToken(value string) (string, error) {
847863
}
848864
lower := strings.ToLower(value)
849865
switch lower {
850-
case "enter", "shift+enter", "esc", "tab", "shift+tab", "backspace", "up", "down":
866+
case "enter", "shift+enter", "esc", "tab", "shift+tab", "backspace", "delete", "up", "down":
851867
return lower, nil
852868
case "leader":
853869
return "", fmt.Errorf("leader is only valid in key bindings, not as a key token")

‎internal/ui/composer.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (m *Model) restoreComposerDraft(chatID string) {
9797
draft := m.composerDrafts[chatID]
9898
draft.Body = m.draftsByChat[chatID]
9999
m.composer = draft.Body
100+
m.composerSelectAll = false
100101
m.composerMentions = slices.Clone(draft.Mentions)
101102
m.replyTo = draft.Reply
102103
m.attachments = nil

‎internal/ui/composer_actions.go‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func (m Model) startComposeEditor() (tea.Model, tea.Cmd) {
4040
m.mode = ModeInsert
4141
m.focus = FocusMessages
4242
m.composer = initial
43+
m.composerSelectAll = false
4344
m.composerMentions = slices.Clone(m.composerMentionsByChat[chatID])
4445
m.clearMentionState()
4546
cmd := m.composeInEditor(chatID, initial)
@@ -68,6 +69,7 @@ func (m Model) beginEditFocusedMessage() (tea.Model, tea.Cmd) {
6869
m.mode = ModeInsert
6970
m.focus = FocusMessages
7071
m.composer = message.Body
72+
m.composerSelectAll = false
7173
m.attachments = nil
7274
m.replyTo = nil
7375
target := message
@@ -100,6 +102,7 @@ func (m Model) handleComposerEdited(msg ComposerEditedMsg) (Model, tea.Cmd) {
100102
m.mode = ModeInsert
101103
m.focus = FocusMessages
102104
m.composer = msg.Body
105+
m.composerSelectAll = false
103106
m.composerMentions = nil
104107
}
105108
m.localSetDraft(chatID, msg.Body)
@@ -137,6 +140,21 @@ func (m Model) beginInsert(quote *store.Message) (tea.Model, tea.Cmd) {
137140
func (m Model) updateInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
138141
m.composerVersion++
139142
keys := m.config.Keymap
143+
if m.keyMatches(msg, keys.InsertSelectAll) {
144+
m.clearMentionState()
145+
m.composerSelectAll = m.composer != ""
146+
return m, nil
147+
}
148+
if m.keyMatches(msg, keys.InsertDelete) {
149+
// The inline composer currently keeps its caret at the end, so Delete
150+
// only has work to do when text is selected.
151+
m.deleteComposerSelection()
152+
return m, nil
153+
}
154+
if m.composerSelectAll && m.keyMatches(msg, keys.InsertCancel) {
155+
m.composerSelectAll = false
156+
return m, nil
157+
}
140158
if updated, cmd, handled := m.handleMentionKey(msg); handled {
141159
return updated, cmd
142160
}
@@ -170,7 +188,7 @@ func (m Model) updateInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
170188
}
171189
if m.keyMatches(msg, keys.InsertNewline) || m.keyMatches(msg, keys.InsertNewlineAlt) {
172190
m.clearMentionState()
173-
m.composer += "\n"
191+
m.appendComposerText("\n")
174192
m.sendOwnPresence(m.currentChat().ID, true)
175193
return m, ownPresenceIdleCmd(m.currentChat().ID, m.ownPresenceGeneration)
176194
}
@@ -180,6 +198,7 @@ func (m Model) updateInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
180198
if m.editTarget != nil {
181199
chatID := m.currentChat().ID
182200
m.composer = ""
201+
m.composerSelectAll = false
183202
m.composerMentions = nil
184203
m.clearMentionState()
185204
m.attachments = nil
@@ -202,6 +221,7 @@ func (m Model) updateInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
202221
if body == "" && len(m.attachments) == 0 {
203222
m.status = "empty message"
204223
m.composer = ""
224+
m.composerSelectAll = false
205225
return m, nil
206226
}
207227
chatID := m.currentChat().ID
@@ -268,6 +288,7 @@ func (m Model) updateInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
268288
m.messageCursor = len(m.messagesByChat[chatID]) - 1
269289
m.messageScrollTop = m.messageCursor
270290
m.composer = ""
291+
m.composerSelectAll = false
271292
m.composerMentions = nil
272293
delete(m.composerMentionsByChat, chatID)
273294
m.clearMentionState()
@@ -342,6 +363,7 @@ func (m *Model) appendComposerText(text string) tea.Cmd {
342363
if text == "" {
343364
return nil
344365
}
366+
m.deleteComposerSelection()
345367
start := len(m.composer)
346368
m.composer += text
347369
if text == "@" && m.canStartMention() {
@@ -363,10 +385,25 @@ func keyText(msg tea.KeyMsg) string {
363385
}
364386

365387
func (m *Model) backspaceComposer() {
388+
if m.deleteComposerSelection() {
389+
return
390+
}
366391
m.composer = trimLastCluster(m.composer)
367392
m.pruneComposerMentions()
368393
}
369394

395+
// Selection is transient and applies only to text, never attachments or replies.
396+
func (m *Model) deleteComposerSelection() bool {
397+
if !m.composerSelectAll {
398+
return false
399+
}
400+
m.composerSelectAll = false
401+
m.composer = ""
402+
m.composerMentions = nil
403+
m.clearMentionState()
404+
return true
405+
}
406+
370407
func (m Model) canStartMention() bool {
371408
if m.editTarget != nil || m.searchMentionCandidates == nil {
372409
return false

0 commit comments

Comments
 (0)