Skip to content

Commit a128fa0

Browse files
committed
nook: type-to-filter the completion popup instead of dismissing on keystroke
With the completion menu open, typing an identifier rune now narrows the visible list to the items whose label still matches the word under the cursor (a case-insensitive subsequence, so "wg" keeps "WaitGroup"), and Backspace widens it back. Previously any key other than an arrow or Enter closed the menu, so a completion was only usable if the first prefix the server saw already surfaced the right item — that is the Zed/VSCode gap. The complete.Popup retains the full server result set and re-filters against it, so widening on Backspace needs no fresh round-trip. The host recomputes the word-prefix from the live buffer each keystroke rather than tracking it incrementally, so paste and multi-rune edits stay honest; an empty prefix or a filter that matches nothing dismisses the popup.
1 parent 2f1c7d8 commit a128fa0

5 files changed

Lines changed: 356 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- nook's completion popup now filters as you type instead of dismissing.
12+
With the menu open, typing an identifier rune narrows the visible list to
13+
the items whose label still matches the word under the cursor (a
14+
case-insensitive subsequence, so `wg` keeps `WaitGroup`), and Backspace
15+
widens it back — matching Zed and VSCode. Previously any key that wasn't
16+
an arrow or Enter closed the menu, so a completion was only usable if the
17+
first typed prefix already surfaced the right item. The filter recomputes
18+
the prefix from the live buffer each keystroke, so paste and multi-rune
19+
edits stay honest, and a prefix that matches nothing dismisses the popup.
20+
921
## [0.51.0] — 2026-06-30
1022

1123
Two files at once. nook gains split panes — the headline Zed-parity

cmd/nook/internal/complete/complete.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ const defaultMaxRows = 8
3535
// the selection. The host treats Popup like any other tea-style value:
3636
// derive a new one from each transition.
3737
type Popup struct {
38+
// all is the full sorted result set the server returned. items is the
39+
// currently-visible subset after any live Narrow filter. Retaining all
40+
// lets Narrow widen back when the user deletes characters without a
41+
// fresh server round-trip.
42+
all []nooklsp.CompletionItem
3843
items []nooklsp.CompletionItem
3944
selected int
4045
prefixLen int
@@ -68,12 +73,65 @@ func (p Popup) WithItems(items []nooklsp.CompletionItem, prefixLen int) Popup {
6873
}
6974
return ordered[i].Label < ordered[j].Label
7075
})
76+
p.all = ordered
7177
p.items = ordered
7278
p.selected = 0
7379
p.prefixLen = prefixLen
7480
return p
7581
}
7682

83+
// Narrow returns a popup whose visible items are those in the full result
84+
// set whose label matches sub as a case-insensitive subsequence, keeping
85+
// the server's SortText order among the survivors. It filters against the
86+
// retained full set rather than the current view, so deleting characters
87+
// widens the list back without a fresh server request. An empty sub
88+
// restores the full set. Selection resets to the first survivor.
89+
//
90+
// Subsequence (not prefix) matching mirrors what fuzzy finders and the Zed
91+
// and VSCode completion menus do as the user keeps typing: "wg" still
92+
// surfaces "WaitGroup". The host feeds sub the identifier prefix under the
93+
// cursor after each keystroke while the popup is open.
94+
func (p Popup) Narrow(sub string) Popup {
95+
if sub == "" {
96+
p.items = p.all
97+
p.selected = 0
98+
return p
99+
}
100+
lower := strings.ToLower(sub)
101+
var kept []nooklsp.CompletionItem
102+
for _, it := range p.all {
103+
label := it.Label
104+
if label == "" {
105+
label = it.InsertText
106+
}
107+
if subsequence(lower, strings.ToLower(label)) {
108+
kept = append(kept, it)
109+
}
110+
}
111+
p.items = kept
112+
p.selected = 0
113+
return p
114+
}
115+
116+
// subsequence reports whether every rune of sub appears in s in order,
117+
// not necessarily contiguously. Both are expected already lower-cased.
118+
func subsequence(sub, s string) bool {
119+
if sub == "" {
120+
return true
121+
}
122+
subRunes := []rune(sub)
123+
i := 0
124+
for _, r := range s {
125+
if r == subRunes[i] {
126+
i++
127+
if i == len(subRunes) {
128+
return true
129+
}
130+
}
131+
}
132+
return false
133+
}
134+
77135
// sortKey is the primary ordering key for an item: its SortText when the
78136
// server supplied one, otherwise its Label. This mirrors the LSP spec's
79137
// fallback rule so a server that omits SortText still ranks sensibly.
@@ -304,6 +362,12 @@ func WordPrefix(s string) string {
304362
return string(runes[i:])
305363
}
306364

365+
// IsIdentRune reports whether r is part of an identifier (letter, digit,
366+
// or underscore). The host uses it to decide whether a keystroke typed
367+
// while the popup is open should narrow the list (identifier rune) or
368+
// dismiss it (anything else).
369+
func IsIdentRune(r rune) bool { return isIdentRune(r) }
370+
307371
func isIdentRune(r rune) bool {
308372
switch {
309373
case r >= 'a' && r <= 'z':

cmd/nook/internal/complete/complete_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,88 @@ func TestNarrowWidthStillRenders(t *testing.T) {
258258
}
259259
}
260260

261+
// TestNarrowFiltersBySubsequence confirms Narrow keeps only the items
262+
// whose label matches the typed prefix as a case-insensitive subsequence,
263+
// preserves SortText order among survivors, and resets the selection.
264+
func TestNarrowFiltersBySubsequence(t *testing.T) {
265+
t.Parallel()
266+
items := []nooklsp.CompletionItem{
267+
{Label: "Print", InsertText: "Print", SortText: "0"},
268+
{Label: "Printf", InsertText: "Printf", SortText: "1"},
269+
{Label: "Println", InsertText: "Println", SortText: "2"},
270+
{Label: "Prepare", InsertText: "Prepare", SortText: "3"},
271+
{Label: "WaitGroup", InsertText: "WaitGroup", SortText: "4"},
272+
}
273+
p := New().WithItems(items, 2)
274+
275+
// "pri" (case-insensitive) drops Prepare (no i after Pr) and WaitGroup.
276+
p = p.Narrow("pri")
277+
got := labels(p.items)
278+
want := []string{"Print", "Printf", "Println"}
279+
if !equalStrings(got, want) {
280+
t.Fatalf("Narrow(pri) = %v, want %v", got, want)
281+
}
282+
// Selection resets to the first survivor.
283+
if sel, ok := p.Selected(); !ok || sel.Label != "Print" {
284+
t.Errorf("after Narrow selected = %v, want Print", sel.Label)
285+
}
286+
287+
// Non-contiguous subsequence still matches: "wg" -> WaitGroup.
288+
p2 := New().WithItems(items, 0).Narrow("wg")
289+
if got := labels(p2.items); !equalStrings(got, []string{"WaitGroup"}) {
290+
t.Errorf("Narrow(wg) = %v, want [WaitGroup]", got)
291+
}
292+
}
293+
294+
// TestNarrowWidensBack confirms Narrow filters the full retained set, not
295+
// the current view, so a shorter prefix restores items an earlier, longer
296+
// prefix had filtered out (the backspace-widens case).
297+
func TestNarrowWidensBack(t *testing.T) {
298+
t.Parallel()
299+
items := []nooklsp.CompletionItem{
300+
{Label: "Print", SortText: "0"},
301+
{Label: "Prepare", SortText: "1"},
302+
}
303+
p := New().WithItems(items, 2)
304+
305+
p = p.Narrow("pri") // drops Prepare
306+
if got := labels(p.items); !equalStrings(got, []string{"Print"}) {
307+
t.Fatalf("Narrow(pri) = %v, want [Print]", got)
308+
}
309+
p = p.Narrow("pr") // widen: both match again
310+
if got := labels(p.items); !equalStrings(got, []string{"Print", "Prepare"}) {
311+
t.Fatalf("Narrow(pr) widen = %v, want [Print Prepare]", got)
312+
}
313+
// Empty restores the full set.
314+
p = p.Narrow("")
315+
if got := labels(p.items); !equalStrings(got, []string{"Print", "Prepare"}) {
316+
t.Errorf("Narrow(\"\") = %v, want full set", got)
317+
}
318+
}
319+
320+
// TestNarrowToEmpty confirms a prefix that matches nothing leaves the
321+
// popup empty so the host can dismiss it.
322+
func TestNarrowToEmpty(t *testing.T) {
323+
t.Parallel()
324+
p := New().WithItems([]nooklsp.CompletionItem{{Label: "Print"}}, 0)
325+
p = p.Narrow("xyz")
326+
if !p.Empty() {
327+
t.Errorf("Narrow(xyz) Len = %d, want empty", p.Len())
328+
}
329+
}
330+
331+
func equalStrings(a, b []string) bool {
332+
if len(a) != len(b) {
333+
return false
334+
}
335+
for i := range a {
336+
if a[i] != b[i] {
337+
return false
338+
}
339+
}
340+
return true
341+
}
342+
261343
func stripANSI(s string) string {
262344
var b strings.Builder
263345
inEsc := false

cmd/nook/main.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ type model struct {
237237
completeReqRow int
238238
completeReqCol int
239239

240+
// completeNarrowPending is set when a keystroke routed while the
241+
// completion popup is open should narrow the visible list rather than
242+
// dismiss it — an identifier rune or a Backspace. The completion
243+
// overlay block arms it and falls through so the key still edits the
244+
// buffer; the post-edit tail then recomputes the word-prefix from the
245+
// live buffer and calls Narrow (or dismisses if nothing matches).
246+
completeNarrowPending bool
247+
240248
// LSP completion-doc side panel. Auto-fires completionItem/resolve as
241249
// the user navigates the popup so the highlighted item's documentation
242250
// renders beside the menu. docReqLabel pins the most recent request so
@@ -2064,10 +2072,12 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
20642072

20652073
// Completion popup intercepts navigation keys: ↑/↓ move selection,
20662074
// Enter accepts (replacing the trailing word-prefix with the chosen
2067-
// item's InsertText), Esc dismisses silently. Any other key dismisses
2068-
// AND falls through to the editor so the user can keep typing — this
2069-
// matches VS Code's behavior where pressing 'a' after the popup is
2070-
// open closes the menu and types 'a'.
2075+
// item's InsertText), Esc dismisses silently. An identifier rune or a
2076+
// Backspace narrows the list live: the key still edits the buffer, and
2077+
// the post-edit tail re-filters the popup against the new word-prefix
2078+
// (Zed/VS Code behavior — typing "Pr" over an open menu keeps only the
2079+
// items that still match instead of closing the menu). Any other key
2080+
// dismisses AND falls through so the keystroke reaches the editor.
20712081
if m.overlay == overlayCompletion {
20722082
switch km.Type {
20732083
case tea.KeyUp:
@@ -2081,9 +2091,21 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
20812091
case tea.KeyEsc:
20822092
m.dismissCompletion()
20832093
return m, nil
2094+
case tea.KeyBackspace:
2095+
// Deleting a char shortens the prefix; narrow (widen) rather
2096+
// than dismiss. If backspacing clears the whole prefix the
2097+
// post-edit tail dismisses.
2098+
m.completeNarrowPending = true
2099+
case tea.KeyRunes:
2100+
if len(km.Runes) == 1 && !km.Alt && complete.IsIdentRune(km.Runes[0]) {
2101+
m.completeNarrowPending = true
2102+
} else {
2103+
m.dismissCompletion()
2104+
}
2105+
default:
2106+
// Any other key: dismiss the popup and fall through.
2107+
m.dismissCompletion()
20842108
}
2085-
// Any other key: dismiss the popup and fall through.
2086-
m.dismissCompletion()
20872109
}
20882110

20892111
// Code-action popup swallows its own keys end-to-end: ↑/↓ navigate,
@@ -2684,6 +2706,27 @@ func (m model) routeKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
26842706
var cmd tea.Cmd
26852707
*p, cmd = p.Update(km)
26862708

2709+
// If the completion popup is open and this keystroke was an identifier
2710+
// rune or a Backspace, narrow the visible list against the freshly
2711+
// edited buffer instead of dismissing. Recomputing the prefix from the
2712+
// live line (rather than tracking it incrementally) keeps the filter
2713+
// honest across paste, multi-rune edits, and word boundaries. An empty
2714+
// prefix or a filter that matches nothing dismisses the popup.
2715+
if m.completeNarrowPending {
2716+
m.completeNarrowPending = false
2717+
if m.overlay == overlayCompletion {
2718+
prefix := complete.WordPrefix(p.LinePrefix())
2719+
if prefix == "" {
2720+
m.dismissCompletion()
2721+
} else {
2722+
m.completePopup = m.completePopup.Narrow(prefix)
2723+
if m.completePopup.Empty() {
2724+
m.dismissCompletion()
2725+
}
2726+
}
2727+
}
2728+
}
2729+
26872730
cmds := []tea.Cmd{}
26882731
if cmd != nil {
26892732
cmds = append(cmds, cmd)

0 commit comments

Comments
 (0)