Skip to content

Commit 54a49db

Browse files
committed
pkg/edit: Provide autosuggestion
1 parent 26a8bd5 commit 54a49db

File tree

7 files changed

+199
-10
lines changed

7 files changed

+199
-10
lines changed

pkg/cli/app.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func NewApp(spec AppSpec) App {
141141
SimpleAbbreviations: spec.SimpleAbbreviations,
142142
CommandAbbreviations: spec.CommandAbbreviations,
143143
SmallWordAbbreviations: spec.SmallWordAbbreviations,
144+
AutoSuggestionProvider: spec.AutoSuggestionProvider,
144145
})
145146

146147
return &a
@@ -267,6 +268,10 @@ func (a *app) redraw(flag redrawFlag) {
267268
a.codeArea.MutateState(func(s *tk.CodeAreaState) {
268269
s.HideTips = true
269270
s.HideRPrompt = hideRPrompt
271+
// Clear autosuggestion on final redraw
272+
if s.Pending.AutoSuggestion {
273+
s.Pending = tk.PendingCode{}
274+
}
270275
})
271276
bufMain := renderApp([]tk.Widget{a.codeArea /* no addon */}, width, height)
272277
a.codeArea.MutateState(func(s *tk.CodeAreaState) {

pkg/cli/app_spec.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type AppSpec struct {
2525
CommandAbbreviations func(f func(abbr, full string))
2626
SmallWordAbbreviations func(f func(abbr, full string))
2727

28+
AutoSuggestionProvider func(code string) string
29+
2830
CodeAreaState tk.CodeAreaState
2931
State State
3032
}

pkg/cli/tk/codearea.go

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type CodeAreaSpec struct {
4848
QuotePaste func() bool
4949
// A function that is called on the submit event.
5050
OnSubmit func()
51+
// A function that returns an autosuggestion for the given code.
52+
AutoSuggestionProvider func(code string) string
5153

5254
// State. When used in New, this field specifies the initial state.
5355
State CodeAreaState
@@ -80,6 +82,8 @@ type PendingCode struct {
8082
To int
8183
// The content of the pending code.
8284
Content string
85+
// Whether this pending code is an autosuggestion (affects styling).
86+
AutoSuggestion bool
8387
}
8488

8589
// ApplyPending applies pending code to the code buffer, and resets pending code.
@@ -141,6 +145,9 @@ func NewCodeArea(spec CodeAreaSpec) CodeArea {
141145
if spec.OnSubmit == nil {
142146
spec.OnSubmit = func() {}
143147
}
148+
if spec.AutoSuggestionProvider == nil {
149+
spec.AutoSuggestionProvider = func(s string) string { return "" }
150+
}
144151
return &codeArea{CodeAreaSpec: spec}
145152
}
146153

@@ -197,6 +204,41 @@ func (w *codeArea) resetInserts() {
197204
w.lastCodeBuffer = CodeBuffer{}
198205
}
199206

207+
// updateAutoSuggestion updates the pending code with an autosuggestion.
208+
// This function assumes the state mutex is held.
209+
func (w *codeArea) updateAutoSuggestion() {
210+
// Don't override non-autosuggestion pending code (e.g., from completion)
211+
if w.State.Pending.Content != "" && !w.State.Pending.AutoSuggestion {
212+
return
213+
}
214+
215+
buf := &w.State.Buffer
216+
// Only suggest when cursor is at the end
217+
if buf.Dot != len(buf.Content) {
218+
// Clear autosuggestion if cursor is not at the end
219+
if w.State.Pending.AutoSuggestion {
220+
w.State.Pending = PendingCode{}
221+
}
222+
return
223+
}
224+
225+
suggestion := w.AutoSuggestionProvider(buf.Content)
226+
if suggestion == "" {
227+
// Clear autosuggestion if there's no suggestion
228+
if w.State.Pending.AutoSuggestion {
229+
w.State.Pending = PendingCode{}
230+
}
231+
return
232+
}
233+
234+
w.State.Pending = PendingCode{
235+
From: buf.Dot,
236+
To: buf.Dot,
237+
Content: suggestion,
238+
AutoSuggestion: true,
239+
}
240+
}
241+
200242
func (w *codeArea) handlePasteSetting(start bool) bool {
201243
w.resetInserts()
202244
if start {
@@ -357,15 +399,17 @@ func (w *codeArea) handleKeyEvent(key ui.Key) bool {
357399
return true
358400
case ui.K(ui.Backspace), ui.K('H', ui.Ctrl):
359401
w.resetInserts()
360-
w.MutateState(func(s *CodeAreaState) {
361-
c := &s.Buffer
362-
// Remove the last rune.
363-
_, chop := utf8.DecodeLastRuneInString(c.Content[:c.Dot])
364-
*c = CodeBuffer{
365-
Content: c.Content[:c.Dot-chop] + c.Content[c.Dot:],
366-
Dot: c.Dot - chop,
367-
}
368-
})
402+
w.StateMutex.Lock()
403+
defer w.StateMutex.Unlock()
404+
c := &w.State.Buffer
405+
// Remove the last rune.
406+
_, chop := utf8.DecodeLastRuneInString(c.Content[:c.Dot])
407+
*c = CodeBuffer{
408+
Content: c.Content[:c.Dot-chop] + c.Content[c.Dot:],
409+
Dot: c.Dot - chop,
410+
}
411+
// Update autosuggestion after backspace
412+
w.updateAutoSuggestion()
369413
return true
370414
default:
371415
if isFuncKey || !unicode.IsGraphic(key.Rune) {
@@ -388,6 +432,7 @@ func (w *codeArea) handleKeyEvent(key ui.Key) bool {
388432
}
389433
w.expandSimpleAbbr()
390434
w.expandSmallWordAbbr(key.Rune, CategorizeSmallWord)
435+
w.updateAutoSuggestion()
391436
return true
392437
}
393438
}

pkg/cli/tk/codearea_render.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type view struct {
1616
}
1717

1818
var stylingForPending = ui.Underlined
19+
var stylingForAutoSuggestion = ui.FgBrightBlack
20+
// var stylingForAutoSuggestion = ui
1921

2022
func getView(w *codeArea) *view {
2123
s := w.CopyState()
@@ -26,8 +28,13 @@ func getView(w *codeArea) *view {
2628
}
2729
if pFrom < pTo {
2830
// Apply stylingForPending to [pFrom, pTo)
31+
styling := stylingForPending
32+
if s.Pending.AutoSuggestion {
33+
styling = stylingForAutoSuggestion
34+
}
35+
// Apply styling to [pFrom, pTo)
2936
parts := styledCode.Partition(pFrom, pTo)
30-
pending := ui.StyleText(parts[1], stylingForPending)
37+
pending := ui.StyleText(parts[1], styling)
3138
styledCode = ui.Concat(parts[0], pending, parts[2])
3239
}
3340

pkg/edit/autosuggest.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package edit
2+
3+
import (
4+
"os"
5+
"strings"
6+
"unicode"
7+
"unicode/utf8"
8+
9+
"src.elv.sh/pkg/cli"
10+
"src.elv.sh/pkg/cli/tk"
11+
"src.elv.sh/pkg/eval"
12+
"src.elv.sh/pkg/eval/vars"
13+
)
14+
15+
func defaultHistoryProvider(hs *histStore, code string) string {
16+
if hs == nil {
17+
return ""
18+
}
19+
if code == "" {
20+
return ""
21+
}
22+
23+
// Get commands from history that start with the current input
24+
// TODO: Optimize this to avoid fetching all commands
25+
cmds, err := hs.AllCmds()
26+
if err != nil {
27+
return ""
28+
}
29+
30+
// Search backwards through history for the most recent match
31+
for i := len(cmds) - 1; i >= 0; i-- {
32+
text := cmds[i].Text
33+
// Match commands that start with current input
34+
// Also handle multiline commands - only match against the first line
35+
if strings.HasPrefix(text, code) && len(text) > len(code) {
36+
text = TrimSpaceRight(text)
37+
// Return the remaining part after the current input
38+
return text[len(code):]
39+
}
40+
}
41+
42+
return ""
43+
}
44+
45+
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
46+
47+
func TrimSpaceRight(s string) string {
48+
start := 0
49+
stop := len(s)
50+
for ; stop > start; stop-- {
51+
c := s[stop-1]
52+
if c >= utf8.RuneSelf {
53+
// start has been already trimmed above, should trim end only
54+
return strings.TrimRightFunc(s[start:stop], unicode.IsSpace)
55+
}
56+
if asciiSpace[c] == 0 {
57+
break
58+
}
59+
}
60+
61+
return s[start:stop]
62+
}
63+
64+
func initAutoSuggestionSpec(appSpec *cli.AppSpec, hs *histStore, enabled vars.PtrVar, provider vars.PtrVar, ev *eval.Evaler) {
65+
appSpec.AutoSuggestionProvider = func(code string) string {
66+
if !enabled.GetRaw().(bool) {
67+
return ""
68+
}
69+
70+
// Check if user has set a custom provider function
71+
providerFn := provider.GetRaw()
72+
if fn, ok := providerFn.(eval.Callable); ok {
73+
// Call user-defined provider function
74+
var result string
75+
valuesCb := func(ch <-chan any) {
76+
for v := range ch {
77+
if s, ok := v.(string); ok && result == "" {
78+
result = s
79+
return
80+
}
81+
}
82+
}
83+
bytesCb := func(_ *os.File) {}
84+
85+
port, done, err := eval.PipePort(valuesCb, bytesCb)
86+
if err != nil {
87+
return ""
88+
}
89+
err = ev.Call(fn,
90+
eval.CallCfg{Args: []any{code}, From: "[auto-suggestion provider]"},
91+
eval.EvalCfg{Ports: []*eval.Port{nil, port, nil}})
92+
done()
93+
if err == nil && result != "" {
94+
return result
95+
}
96+
return ""
97+
}
98+
99+
// Fall back to default history-based provider
100+
return defaultHistoryProvider(hs, code)
101+
}
102+
}
103+
104+
func initAutoSuggestionAPI(app cli.App, enabled vars.PtrVar, provider vars.PtrVar, nb eval.NsBuilder) {
105+
acceptFn := func() {
106+
codeArea, ok := focusedCodeArea(app)
107+
if !ok {
108+
return
109+
}
110+
codeArea.MutateState(func(s *tk.CodeAreaState) {
111+
// Only accept if there's an autosuggestion pending
112+
if s.Pending.Content != "" && s.Pending.AutoSuggestion {
113+
s.ApplyPending()
114+
}
115+
})
116+
}
117+
118+
nb.AddNs("auto-suggestion",
119+
eval.BuildNsNamed("edit:auto-suggestion").
120+
AddVar("enabled", enabled).
121+
AddVar("provider", provider).
122+
AddGoFn("accept", acceptFn))
123+
}

pkg/edit/editor.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,19 @@ func NewEditor(tty cli.TTY, ev *eval.Evaler, st storedefs.Store) *Editor {
6363
_ = err // TODO(xiaq): Report the error.
6464
}
6565

66+
autoSuggestEnabled := newBoolVar(true)
67+
var autoSuggestProviderFn eval.Callable
68+
autoSuggestProvider := newFnVar(autoSuggestProviderFn)
6669
initMaxHeight(&appSpec, nb)
6770
initReadlineHooks(&appSpec, ev, nb)
6871
initAddCmdFilters(&appSpec, ev, nb, hs)
6972
initGlobalBindings(&appSpec, ed, ev, nb)
7073
initInsertAPI(&appSpec, ed, ev, nb)
7174
initHighlighter(&appSpec, ed, ev, nb)
7275
initPrompts(&appSpec, ed, ev, nb)
76+
initAutoSuggestionSpec(&appSpec, hs, autoSuggestEnabled, autoSuggestProvider, ev)
7377
ed.app = cli.NewApp(appSpec)
78+
initAutoSuggestionAPI(ed.app, autoSuggestEnabled, autoSuggestProvider, nb)
7479

7580
initExceptionsAPI(ed, nb)
7681
initVarsAPI(nb)

pkg/edit/init.elv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ set insert:binding = (binding-table [
4141
&Ctrl-U= $kill-line-left~
4242
&Ctrl-K= $kill-line-right~
4343

44+
&Ctrl-F= $auto-suggestion:accept~
45+
4446
&Ctrl-V= $insert-raw~
4547
&Ctrl-Alt-V= $-insert-key-name~
4648

0 commit comments

Comments
 (0)