From c86f03b4d5f4402e8d96db21a1912b16cfb28b38 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:25:28 +0900 Subject: [PATCH 1/7] Add completion acceptance callback --- internal/completion/completion.go | 1 + internal/completion/insert.go | 4 ++++ internal/completion/insert_test.go | 33 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/internal/completion/completion.go b/internal/completion/completion.go index 0345cb52..0e6ef4b6 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -12,6 +12,7 @@ type Candidate struct { Description string // A description to display next to the completion candidate. Style string // An arbitrary string of color/text effects to use when displaying the completion. Tag string // All completions with the same tag are grouped together and displayed under the tag heading. + OnAccept func() // OnAccept is called after the candidate is inserted into the real input line. displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. descLen int diff --git a/internal/completion/insert.go b/internal/completion/insert.go index ffdc02a4..0f6a4a69 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -195,6 +195,10 @@ func (e *Engine) acceptCandidate() { e.inserted = make([]rune, 0) e.prefix = "" e.suffix = "" + + if e.selected.OnAccept != nil { + e.selected.OnAccept() + } } // insertCandidate inserts a completion candidate into the virtual (completed) line. diff --git a/internal/completion/insert_test.go b/internal/completion/insert_test.go index 68f70106..7934d0f6 100644 --- a/internal/completion/insert_test.go +++ b/internal/completion/insert_test.go @@ -106,3 +106,36 @@ func TestInsertCommonPrefix(t *testing.T) { } }) } + +func TestAcceptCandidateCallsOnAcceptAfterInsertion(t *testing.T) { + line := core.Line([]rune("rea")) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + accepted := false + grp := &group{ + rows: [][]Candidate{{{ + Value: "readline", + OnAccept: func() { + if got := string(line); got != "readline" { + t.Fatalf("line during OnAccept = %q, want %q", got, "readline") + } + accepted = true + }, + }}}, + } + engine := &Engine{ + line: &line, + cursor: cursor, + prefix: "rea", + groups: []*group{grp}, + } + + engine.acceptCandidate() + + if !accepted { + t.Fatal("OnAccept was not called") + } + if got := cursor.Pos(); got != len([]rune("readline")) { + t.Fatalf("cursor = %d, want %d", got, len([]rune("readline"))) + } +} From 0403be00f73d81ece9651ee62e0ace85c6fb8c86 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:28:48 +0900 Subject: [PATCH 2/7] Notify callbacks for virtual completion acceptance --- internal/completion/engine.go | 1 + internal/completion/insert.go | 4 ++++ internal/completion/insert_test.go | 31 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/internal/completion/engine.go b/internal/completion/engine.go index bfe078c3..b0a4e70b 100644 --- a/internal/completion/engine.go +++ b/internal/completion/engine.go @@ -336,6 +336,7 @@ func (e *Engine) Cancel(inserted, cached bool) { } else { e.line.Set(*e.compLine...) e.cursor.Set(e.compCursor.Pos()) + e.notifyAccepted() } } diff --git a/internal/completion/insert.go b/internal/completion/insert.go index 0f6a4a69..a23b675b 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -196,6 +196,10 @@ func (e *Engine) acceptCandidate() { e.prefix = "" e.suffix = "" + e.notifyAccepted() +} + +func (e *Engine) notifyAccepted() { if e.selected.OnAccept != nil { e.selected.OnAccept() } diff --git a/internal/completion/insert_test.go b/internal/completion/insert_test.go index 7934d0f6..4d0787fc 100644 --- a/internal/completion/insert_test.go +++ b/internal/completion/insert_test.go @@ -139,3 +139,34 @@ func TestAcceptCandidateCallsOnAcceptAfterInsertion(t *testing.T) { t.Fatalf("cursor = %d, want %d", got, len([]rune("readline"))) } } + +func TestCancelCallsOnAcceptWhenVirtualCandidateBecomesReal(t *testing.T) { + line := core.Line([]rune("rea")) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + completed := core.Line([]rune("readline")) + completedCursor := core.NewCursor(&completed) + completedCursor.Set(completed.Len()) + accepted := false + engine := &Engine{ + line: &line, + cursor: cursor, + compLine: &completed, + compCursor: completedCursor, + selected: Candidate{Value: "readline", OnAccept: func() { + if got := string(line); got != "readline" { + t.Fatalf("line during OnAccept = %q, want %q", got, "readline") + } + accepted = true + }}, + } + + engine.Cancel(false, false) + + if !accepted { + t.Fatal("OnAccept was not called") + } + if got := cursor.Pos(); got != len([]rune("readline")) { + t.Fatalf("cursor = %d, want %d", got, len([]rune("readline"))) + } +} From 6966975c43a48d13ea6f133256e228a90252a1d7 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:33:00 +0900 Subject: [PATCH 3/7] Accept selected candidates after menu exit --- internal/completion/engine.go | 2 +- internal/completion/insert_test.go | 31 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/completion/engine.go b/internal/completion/engine.go index b0a4e70b..b9a811c0 100644 --- a/internal/completion/engine.go +++ b/internal/completion/engine.go @@ -360,7 +360,7 @@ func (e *Engine) ResetForce() { // If the completion engine was not active to begin with, nothing will happen. func (e *Engine) Reset() { e.autoForce = false - if !e.IsActive() { + if !e.IsActive() && !e.IsInserting() { e.ClearMenu(true) return } diff --git a/internal/completion/insert_test.go b/internal/completion/insert_test.go index 4d0787fc..16260e50 100644 --- a/internal/completion/insert_test.go +++ b/internal/completion/insert_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/reeflective/readline/internal/core" + "github.com/reeflective/readline/internal/keymap" + "github.com/reeflective/readline/internal/ui" ) // newPrefixEngine builds a minimal engine whose menu already holds the given @@ -170,3 +172,32 @@ func TestCancelCallsOnAcceptWhenVirtualCandidateBecomesReal(t *testing.T) { t.Fatalf("cursor = %d, want %d", got, len([]rune("readline"))) } } + +func TestResetAcceptsSelectedCandidateAfterMenuKeymapEnds(t *testing.T) { + keys := new(core.Keys) + iterations := new(core.Iterations) + keymaps, config := keymap.NewEngine(keys, iterations) + hint := ui.NewHint(keys) + line := core.Line([]rune("rea")) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + selection := core.NewSelection(&line, cursor) + engine := NewEngine(hint, keymaps, config) + Init(engine, keys, &line, cursor, selection, nil) + completed := core.Line([]rune("readline")) + completedCursor := core.NewCursor(&completed) + completedCursor.Set(completed.Len()) + accepted := false + engine.compLine = &completed + engine.compCursor = completedCursor + engine.selected = Candidate{Value: "readline", OnAccept: func() { accepted = true }} + + engine.Reset() + + if !accepted { + t.Fatal("OnAccept was not called") + } + if got := string(line); got != "readline" { + t.Fatalf("line = %q, want %q", got, "readline") + } +} From 1608fe860d1fce9a6504b1d40468d991aad3b5c6 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:42:14 +0900 Subject: [PATCH 4/7] Allow accepted completions to transform input --- completion_accept_test.go | 38 ++++++++++++++++++++++++++++++ internal/completion/completion.go | 5 +++- internal/completion/insert.go | 10 ++++++-- internal/completion/insert_test.go | 15 ++++++++---- 4 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 completion_accept_test.go diff --git a/completion_accept_test.go b/completion_accept_test.go new file mode 100644 index 00000000..8f734264 --- /dev/null +++ b/completion_accept_test.go @@ -0,0 +1,38 @@ +package readline + +import "testing" + +func TestResetNotifiesSelectedPublicCompletion(t *testing.T) { + shell := NewShell() + accepted := "" + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{ + {Value: "sha256", Display: "sha256", Description: "hmac", Tag: "function", OnAccept: func(_ []rune, _ int) ([]rune, int) { + accepted = "hmac" + return []rune("import hmac"), len([]rune("import hmac")) + }}, + {Value: "sha256", Display: "sha256", Description: "hash", Tag: "function", OnAccept: func(_ []rune, _ int) ([]rune, int) { + accepted = "hash" + return []rune("import hash"), len([]rune("import hash")) + }}, + }).JustifyDescriptions() + result.PREFIX = "sha" + return result + } + if err := shell.Config.Set("menu-complete-display-prefix", true); err != nil { + t.Fatal(err) + } + shell.Line().Set([]rune("sha")...) + shell.Cursor().Set(3) + + shell.completeWord() + shell.completeWord() + shell.completer.Reset() + + if accepted == "" { + t.Fatal("selected completion did not call OnAccept") + } + if got := string(*shell.Line()); got != "import "+accepted { + t.Fatalf("line = %q, want transformed %s completion", got, accepted) + } +} diff --git a/internal/completion/completion.go b/internal/completion/completion.go index 0e6ef4b6..e246aebe 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -12,7 +12,10 @@ type Candidate struct { Description string // A description to display next to the completion candidate. Style string // An arbitrary string of color/text effects to use when displaying the completion. Tag string // All completions with the same tag are grouped together and displayed under the tag heading. - OnAccept func() // OnAccept is called after the candidate is inserted into the real input line. + // OnAccept may transform the real input line after this candidate is + // accepted. The returned cursor is a rune offset into the returned line. + // Returning an invalid cursor leaves the accepted candidate unchanged. + OnAccept func(line []rune, cursor int) (accepted []rune, acceptedCursor int) displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. descLen int diff --git a/internal/completion/insert.go b/internal/completion/insert.go index a23b675b..82e85522 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -200,9 +200,15 @@ func (e *Engine) acceptCandidate() { } func (e *Engine) notifyAccepted() { - if e.selected.OnAccept != nil { - e.selected.OnAccept() + if e.selected.OnAccept == nil { + return + } + line, cursor := e.selected.OnAccept(append([]rune(nil), (*e.line)...), e.cursor.Pos()) + if cursor < 0 || cursor > len(line) { + return } + e.line.Set(line...) + e.cursor.Set(cursor) } // insertCandidate inserts a completion candidate into the virtual (completed) line. diff --git a/internal/completion/insert_test.go b/internal/completion/insert_test.go index 16260e50..14c0e951 100644 --- a/internal/completion/insert_test.go +++ b/internal/completion/insert_test.go @@ -117,11 +117,12 @@ func TestAcceptCandidateCallsOnAcceptAfterInsertion(t *testing.T) { grp := &group{ rows: [][]Candidate{{{ Value: "readline", - OnAccept: func() { + OnAccept: func(line []rune, cursor int) ([]rune, int) { if got := string(line); got != "readline" { t.Fatalf("line during OnAccept = %q, want %q", got, "readline") } accepted = true + return line, cursor }, }}}, } @@ -155,11 +156,12 @@ func TestCancelCallsOnAcceptWhenVirtualCandidateBecomesReal(t *testing.T) { cursor: cursor, compLine: &completed, compCursor: completedCursor, - selected: Candidate{Value: "readline", OnAccept: func() { + selected: Candidate{Value: "readline", OnAccept: func(line []rune, cursor int) ([]rune, int) { if got := string(line); got != "readline" { t.Fatalf("line during OnAccept = %q, want %q", got, "readline") } accepted = true + return line, cursor }}, } @@ -190,14 +192,17 @@ func TestResetAcceptsSelectedCandidateAfterMenuKeymapEnds(t *testing.T) { accepted := false engine.compLine = &completed engine.compCursor = completedCursor - engine.selected = Candidate{Value: "readline", OnAccept: func() { accepted = true }} + engine.selected = Candidate{Value: "readline", OnAccept: func(line []rune, cursor int) ([]rune, int) { + accepted = true + return []rune("accepted"), len([]rune("accepted")) + }} engine.Reset() if !accepted { t.Fatal("OnAccept was not called") } - if got := string(line); got != "readline" { - t.Fatalf("line = %q, want %q", got, "readline") + if got := string(line); got != "accepted" { + t.Fatalf("line = %q, want %q", got, "accepted") } } From 5a6f0637e0b4a396d08da1e74f978eda32db7b36 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:55:38 +0900 Subject: [PATCH 5/7] Keep explicit completions editable after confirmation --- completion.go | 6 ++++ completion_accept_test.go | 59 +++++++++++++++++++++++++++++++ history.go | 6 ++++ internal/completion/completion.go | 4 +++ internal/completion/engine.go | 27 +++++++++++++- internal/completion/insert.go | 2 +- internal/keymap/dispatch.go | 7 ++++ 7 files changed, 109 insertions(+), 2 deletions(-) diff --git a/completion.go b/completion.go index f18b1579..efe2f13c 100644 --- a/completion.go +++ b/completion.go @@ -41,6 +41,12 @@ func (rl *Shell) completeWord() { rl.startMenuComplete(rl.commandCompletion) if rl.Config.GetBool("menu-complete-display-prefix") { + if rl.completer.RequiresConfirmation() { + // Keep an explicit-confirmation candidate virtual so Escape can + // restore the original input and Enter can commit without submit. + rl.completer.Select(1, 0) + return + } // Insert the prefix shared by all candidates, then display the // menu without selecting one (GNU menu-complete-display-prefix). rl.completer.InsertCommonPrefix() diff --git a/completion_accept_test.go b/completion_accept_test.go index 8f734264..5fa43b60 100644 --- a/completion_accept_test.go +++ b/completion_accept_test.go @@ -36,3 +36,62 @@ func TestResetNotifiesSelectedPublicCompletion(t *testing.T) { t.Fatalf("line = %q, want transformed %s completion", got, accepted) } } + +func TestConfirmationRequiredCompletionCanBeCancelledOrCommittedWithoutSubmit(t *testing.T) { + shell := NewShell() + accepted := false + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{{ + Value: "math", Display: "math", Description: "trb/std/math", Tag: "module", + RequireConfirmation: true, + OnAccept: func(_ []rune, _ int) ([]rune, int) { + accepted = true + line := []rune("import trb/std/math") + return line, len(line) + }, + }}) + result.PREFIX = "mat" + return result + } + if err := shell.Config.Set("menu-complete-display-prefix", true); err != nil { + t.Fatal(err) + } + shell.Line().Set([]rune("mat")...) + shell.Cursor().Set(3) + + shell.completeWord() + if got := string(*shell.Line()); got != "mat" { + t.Fatalf("real line after completion = %q, want original input", got) + } + if !shell.completer.IsInserting() { + t.Fatal("confirmation-required completion was not kept virtual") + } + shell.abort() + if got := string(*shell.Line()); got != "mat" { + t.Fatalf("line after cancellation = %q, want original input", got) + } + if accepted { + t.Fatal("cancelled completion called OnAccept") + } + + shell.completeWord() + shell.acceptLine() + if !accepted { + t.Fatal("confirmed completion did not call OnAccept") + } + if got := string(*shell.Line()); got != "import trb/std/math" { + t.Fatalf("line after confirmation = %q, want import", got) + } + if lineAccepted, _, _ := shell.History.LineAccepted(); lineAccepted { + t.Fatal("confirmation submitted the line") + } + + shell.acceptLine() + lineAccepted, line, err := shell.History.LineAccepted() + if err != nil { + t.Fatal(err) + } + if !lineAccepted || line != "import trb/std/math" { + t.Fatalf("second accept = (%v, %q), want submitted import", lineAccepted, line) + } +} diff --git a/history.go b/history.go index ff4c7d0b..9e70f03e 100644 --- a/history.go +++ b/history.go @@ -652,6 +652,12 @@ func (rl *Shell) autosuggestDisable() { // func (rl *Shell) acceptLineWith(infer, hold bool) { + // Confirmation-required completions use the first Enter to commit the + // selected candidate to the editable input. A later Enter submits it. + if rl.completer.ConfirmSelection() { + return + } + // If we are currently using the incremental-search buffer, // we should cancel this mode so as to run the rest of this // function on (with) the input line itself, not the minibuffer. diff --git a/internal/completion/completion.go b/internal/completion/completion.go index e246aebe..f2ee5282 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -12,6 +12,10 @@ type Candidate struct { Description string // A description to display next to the completion candidate. Style string // An arbitrary string of color/text effects to use when displaying the completion. Tag string // All completions with the same tag are grouped together and displayed under the tag heading. + // RequireConfirmation keeps this candidate virtual even when it is the only + // match. The first accept-line commits it to the editable input without + // submitting the line; a later accept-line submits the committed input. + RequireConfirmation bool // OnAccept may transform the real input line after this candidate is // accepted. The returned cursor is a rune offset into the returned line. // Returning an invalid cursor leaves the accepted candidate unchanged. diff --git a/internal/completion/engine.go b/internal/completion/engine.go index b9a811c0..d783ebf9 100644 --- a/internal/completion/engine.go +++ b/internal/completion/engine.go @@ -89,7 +89,7 @@ func (e *Engine) Generate(completions Values) { // Incremental search is a special case, because the user may // want to keep searching for another match, so we don't drop // the completion list and exit the incremental search mode. - if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch { + if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch && !e.requiresConfirmation() { e.acceptCandidate() e.ClearMenu(true) } @@ -400,6 +400,31 @@ func (e *Engine) IsInserting() bool { return e.selected.Value != "" } +// ConfirmSelection commits a selected confirmation-required candidate while +// leaving the input open for further editing. It reports whether it consumed +// the caller's accept-line action. +func (e *Engine) ConfirmSelection() bool { + if !e.IsInserting() || !e.selected.RequireConfirmation { + return false + } + e.Reset() + return true +} + +// RequiresConfirmation reports whether the candidate that would be selected +// next must be explicitly confirmed. +func (e *Engine) RequiresConfirmation() bool { + return e.requiresConfirmation() +} + +func (e *Engine) requiresConfirmation() bool { + if e.IsInserting() { + return e.selected.RequireConfirmation + } + group := e.currentGroup() + return group != nil && group.selected().RequireConfirmation +} + // Matches returns the number of completion candidates // matching the current line/settings requirements. func (e *Engine) Matches() int { diff --git a/internal/completion/insert.go b/internal/completion/insert.go index 82e85522..e3bbd003 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -93,7 +93,7 @@ func (e *Engine) refreshLine() { // Incremental search is a special case, because the user may // want to keep searching for another match, so we don't drop // the completion list and exit the incremental search mode. - if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch { + if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch && !e.requiresConfirmation() { e.acceptCandidate() e.ResetForce() } else { diff --git a/internal/keymap/dispatch.go b/internal/keymap/dispatch.go index 3b022d12..12dae879 100644 --- a/internal/keymap/dispatch.go +++ b/internal/keymap/dispatch.go @@ -241,6 +241,13 @@ func (m *Engine) handleEscape(main bool) (bind inputrc.Bind, cmd func(), pref bo core.PopForce(m.keys) + case !main && m.Local() == MenuSelect: + // Escape cancels a completion menu and restores any virtually + // inserted candidate instead of committing it to the real line. + bind = inputrc.Bind{Action: "abort"} + + core.PopForce(m.keys) + case !main: // When using the local keymap, we simply drop any prefixed // or matched bind, so that the key will be matched against From 9d41c4c90b9e0fe79a264af52d3324a70473e1b9 Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:12:10 +0900 Subject: [PATCH 6/7] Separate completion confirmation from submission --- completion_accept_test.go | 92 ++++++++++++++++++++++++++++++++++- internal/completion/insert.go | 2 +- internal/keymap/completion.go | 6 +++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/completion_accept_test.go b/completion_accept_test.go index 5fa43b60..71aba2f8 100644 --- a/completion_accept_test.go +++ b/completion_accept_test.go @@ -1,6 +1,9 @@ package readline -import "testing" +import ( + "testing" + "time" +) func TestResetNotifiesSelectedPublicCompletion(t *testing.T) { shell := NewShell() @@ -95,3 +98,90 @@ func TestConfirmationRequiredCompletionCanBeCancelledOrCommittedWithoutSubmit(t t.Fatalf("second accept = (%v, %q), want submitted import", lineAccepted, line) } } + +func TestConfirmationRequiredCompletionConsumesFirstEnter(t *testing.T) { + shell := confirmationTestShell(t) + accepted := make(chan struct{}, 1) + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{{ + Value: "math", Display: "math", Description: "trb/std/math", Tag: "module", + RequireConfirmation: true, + OnAccept: func(_ []rune, _ int) ([]rune, int) { + accepted <- struct{}{} + line := []rune("import trb/std/math") + return line, len(line) + }, + }}) + result.PREFIX = "mat" + return result + } + + shell.Keys.Feed(false, []rune("mat\t\r")...) + result := make(chan string, 1) + go func() { + line, _ := shell.Readline() + result <- line + }() + + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("first Enter did not confirm the completion") + } + select { + case line := <-result: + t.Fatalf("first Enter submitted %q instead of keeping it editable", line) + case <-time.After(50 * time.Millisecond): + } + + shell.Keys.Feed(false, '\r') + shell.Keys.RequestRefresh() + select { + case line := <-result: + if line != "import trb/std/math" { + t.Fatalf("second Enter submitted %q, want import", line) + } + case <-time.After(time.Second): + t.Fatal("second Enter did not submit the confirmed import") + } +} + +func TestConfirmationRequiredCompletionBackspaceCancelsSelection(t *testing.T) { + shell := confirmationTestShell(t) + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{{ + Value: "math", Display: "math", Description: "trb/std/math", Tag: "module", + RequireConfirmation: true, + OnAccept: func(_ []rune, _ int) ([]rune, int) { + t.Fatal("cancelled completion called OnAccept") + return nil, 0 + }, + }}) + result.PREFIX = "mat" + return result + } + + shell.Keys.Feed(false, []rune("mat\t\x7f\r")...) + line, err := shell.Readline() + if err != nil { + t.Fatal(err) + } + if line != "mat" { + t.Fatalf("line after Backspace cancellation = %q, want original input", line) + } +} + +func confirmationTestShell(t *testing.T) *Shell { + t.Helper() + shell := NewShell() + for name, value := range map[string]bool{ + "cursor-position-probe": false, + "enable-bracketed-paste": false, + "menu-complete-display-prefix": true, + } { + if err := shell.Config.Set(name, value); err != nil { + t.Fatal(err) + } + } + return shell +} diff --git a/internal/completion/insert.go b/internal/completion/insert.go index e3bbd003..90f90601 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -256,7 +256,7 @@ func (e *Engine) prepareSuffix() (comp string) { // When the completion has a size of 1, don't remove anything: // stacked flags, for example, will never be inserted otherwise. if len(comp) > 0 && len(comp[prefix:]) <= 1 { - return + return comp } // If we are to even consider removing a suffix, we keep the suffix diff --git a/internal/keymap/completion.go b/internal/keymap/completion.go index 1a05a537..9e71fd51 100644 --- a/internal/keymap/completion.go +++ b/internal/keymap/completion.go @@ -8,6 +8,12 @@ import ( // menuselectKeys are the default keymaps in menuselect mode. var menuselectKeys = map[string]inputrc.Bind{ + unescape(`\C-j`): {Action: "accept-line"}, + unescape(`\C-m`): {Action: "accept-line"}, + unescape(`\C-h`): {Action: "abort"}, + unescape(`\C-?`): {Action: "abort"}, + unescape(`\C-d`): {Action: "abort"}, + unescape(`\e[3~`): {Action: "abort"}, unescape(`\C-i`): {Action: "menu-complete"}, unescape(`\C-N`): {Action: "menu-complete"}, unescape(`\C-P`): {Action: "menu-complete-backward"}, From 991cae418b2346b21c31a6c217eb84535bd6689b Mon Sep 17 00:00:00 2001 From: hiroki23 <33798+hiroki23@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:30:00 +0900 Subject: [PATCH 7/7] Add completion commit characters --- completion_accept_test.go | 59 +++++++++++++++++++++++++++++++ internal/completion/completion.go | 7 ++++ internal/completion/insert.go | 42 ++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/completion_accept_test.go b/completion_accept_test.go index 71aba2f8..72e7a4d5 100644 --- a/completion_accept_test.go +++ b/completion_accept_test.go @@ -171,6 +171,65 @@ func TestConfirmationRequiredCompletionBackspaceCancelsSelection(t *testing.T) { } } +func TestConfirmationRequiredCompletionCommitCharacterKeepsExpression(t *testing.T) { + shell := confirmationTestShell(t) + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{{ + Value: "math", Display: "math", Description: "trb/std/math", Tag: "module", + RequireConfirmation: true, + CommitCharacters: ".", + OnAccept: func(_ []rune, _ int) ([]rune, int) { + t.Fatal("commit character used the Enter acceptance path") + return nil, 0 + }, + OnCommit: func(_ []rune, _ int, character rune) ([]rune, int) { + if character != '.' { + t.Fatalf("commit character = %q, want '.'", character) + } + line := []rune("import trb/std/math\nmath") + return line, len(line) + }, + }}) + result.PREFIX = "mat" + return result + } + + shell.Keys.Feed(false, []rune("mat\t.sqrt(9)\r")...) + line, err := shell.Readline() + if err != nil { + t.Fatal(err) + } + if line != "import trb/std/math\nmath.sqrt(9)" { + t.Fatalf("committed line = %q, want imported expression", line) + } +} + +func TestConfirmationRequiredCompletionOrdinaryInputCancelsSelection(t *testing.T) { + shell := confirmationTestShell(t) + shell.Completer = func(_ []rune, _ int) Completions { + result := CompleteRaw([]Completion{{ + Value: "math", Display: "math", Description: "trb/std/math", Tag: "module", + RequireConfirmation: true, + CommitCharacters: ".", + OnAccept: func(_ []rune, _ int) ([]rune, int) { + t.Fatal("ordinary input accepted the candidate") + return nil, 0 + }, + }}) + result.PREFIX = "mat" + return result + } + + shell.Keys.Feed(false, []rune("mat\tx\r")...) + line, err := shell.Readline() + if err != nil { + t.Fatal(err) + } + if line != "matx" { + t.Fatalf("line after ordinary input = %q, want original input plus character", line) + } +} + func confirmationTestShell(t *testing.T) *Shell { t.Helper() shell := NewShell() diff --git a/internal/completion/completion.go b/internal/completion/completion.go index f2ee5282..dea18b0b 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -16,10 +16,17 @@ type Candidate struct { // match. The first accept-line commits it to the editable input without // submitting the line; a later accept-line submits the committed input. RequireConfirmation bool + // CommitCharacters accepts this candidate before the matching character is + // inserted by the editor. This is useful for continuations such as '.' or + // '(' that should keep the line open. + CommitCharacters string // OnAccept may transform the real input line after this candidate is // accepted. The returned cursor is a rune offset into the returned line. // Returning an invalid cursor leaves the accepted candidate unchanged. OnAccept func(line []rune, cursor int) (accepted []rune, acceptedCursor int) + // OnCommit may apply a different transformation when a CommitCharacter + // accepts the candidate. When nil, OnAccept is used instead. + OnCommit func(line []rune, cursor int, character rune) (accepted []rune, acceptedCursor int) displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. descLen int diff --git a/internal/completion/insert.go b/internal/completion/insert.go index 90f90601..123ccb33 100644 --- a/internal/completion/insert.go +++ b/internal/completion/insert.go @@ -1,6 +1,7 @@ package completion import ( + "strings" "unicode" "github.com/reeflective/readline/inputrc" @@ -31,6 +32,14 @@ func UpdateInserted(eng *Engine) { // to quit incremental search but keeping any selected comp. inserted := eng.mustRemoveInserted() cached := eng.keymap.Local() != keymap.Isearch && !eng.autoForce + if eng.commitSelectedCharacter(cached) { + return + } + if eng.IsInserting() && eng.selected.RequireConfirmation && eng.keymap.Local() == keymap.MenuSelect { + // Ordinary editing cancels a confirmation-required selection before + // the main keymap applies the key to the original input. + inserted = true + } eng.Cancel(inserted, cached) @@ -211,6 +220,39 @@ func (e *Engine) notifyAccepted() { e.cursor.Set(cursor) } +func (e *Engine) commitSelectedCharacter(cached bool) bool { + if !e.IsInserting() || !e.selected.RequireConfirmation || e.keymap.Local() != keymap.MenuSelect { + return false + } + key, empty := core.PeekKey(e.keys) + if empty || !strings.ContainsRune(e.selected.CommitCharacters, rune(key)) { + return false + } + if cached { + e.cached = nil + e.hint.Reset() + } + + defer e.cancelCompletedLine() + e.line.Set(*e.compLine...) + e.cursor.Set(e.compCursor.Pos()) + e.notifyCommitted(rune(key)) + return true +} + +func (e *Engine) notifyCommitted(character rune) { + if e.selected.OnCommit == nil { + e.notifyAccepted() + return + } + line, cursor := e.selected.OnCommit(append([]rune(nil), (*e.line)...), e.cursor.Pos(), character) + if cursor < 0 || cursor > len(line) { + return + } + e.line.Set(line...) + e.cursor.Set(cursor) +} + // insertCandidate inserts a completion candidate into the virtual (completed) line. func (e *Engine) insertCandidate() { grp := e.currentGroup()