From 4ecea75f78baf1e0ef9bedd8c1275cd3f22bc559 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:03:08 +0900 Subject: [PATCH 1/2] feat(interactive): add fzf-style fuzzy finder for client-side statements Add an interactive fuzzy finder triggered by a configurable key binding (default: Ctrl+T) that provides completion-style selection for client-side statement arguments. MVP supports USE context. - Add ktr0731/go-fuzzyfinder dependency (pure Go, tcell-based) - Upgrade go-multiline-ny v0.21.0 -> v0.22.4, go-readline-ny v1.9.1 -> v1.14.1 - Add CLI_ENABLE_FUZZY_FINDER (default: true) and CLI_FUZZY_FINDER_KEY (default: C_T) system variables for user configuration - Implement fuzzyFinderCommand with terminal handoff pattern (GotoEndLine/rewind) and completion-style argument replacement - Detect USE context, pre-fill fzf query with typed prefix, replace argument portion on selection Co-Authored-By: Claude Opus 4.6 --- go.mod | 16 ++- go.sum | 34 +++++- internal/mycli/cli_readline.go | 13 +++ internal/mycli/fuzzy_finder.go | 164 ++++++++++++++++++++++++++++ internal/mycli/fuzzy_finder_test.go | 100 +++++++++++++++++ internal/mycli/main_test.go | 36 +++++- internal/mycli/system_variables.go | 6 + internal/mycli/var_registry.go | 4 + 8 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 internal/mycli/fuzzy_finder.go create mode 100644 internal/mycli/fuzzy_finder_test.go diff --git a/go.mod b/go.mod index 12bc1619..21a4812f 100644 --- a/go.mod +++ b/go.mod @@ -30,14 +30,15 @@ require ( github.com/google/go-cmp v0.7.0 github.com/googleapis/go-spanner-cassandra v0.5.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 - github.com/hymkor/go-multiline-ny v0.21.0 + github.com/hymkor/go-multiline-ny v0.22.4 github.com/jessevdk/go-flags v1.6.1 github.com/k0kubun/pp/v3 v3.5.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-runewidth v0.0.16 + github.com/ktr0731/go-fuzzyfinder v0.9.0 + github.com/mattn/go-runewidth v0.0.19 github.com/modelcontextprotocol/go-sdk v0.3.0 github.com/ngicks/go-iterator-helper v0.0.21 - github.com/nyaosorg/go-readline-ny v1.9.1 + github.com/nyaosorg/go-readline-ny v1.14.1 github.com/olekukonko/tablewriter v1.0.9 github.com/samber/lo v1.51.0 github.com/sourcegraph/conc v0.3.0 @@ -47,7 +48,6 @@ require ( github.com/testcontainers/testcontainers-go/modules/gcloud v0.38.0 github.com/vbauerster/mpb/v8 v8.10.2 go.uber.org/zap v1.27.0 - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b golang.org/x/term v0.36.0 google.golang.org/api v0.256.0 google.golang.org/genai v1.21.0 @@ -80,6 +80,7 @@ require ( github.com/apstndb/treeprint v0.0.0-20250529153958-e82576b37da6 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -97,6 +98,8 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -115,6 +118,8 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/ktr0731/go-ansisgr v0.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -130,6 +135,8 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/nyaosorg/go-ttyadapter v0.3.0 // indirect github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.0 // indirect @@ -166,6 +173,7 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect diff --git a/go.sum b/go.sum index e31aff59..d4fd8f06 100644 --- a/go.sum +++ b/go.sum @@ -2524,6 +2524,8 @@ github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86c github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudspannerecosystem/memefish v0.6.2 h1:0R6C8KdJLLbL3aYk/rzWrwvE+bPRMqj/2MNlNvAzIPo= github.com/cloudspannerecosystem/memefish v0.6.2/go.mod h1:mVw0xBxy0yOgm990BuR0+nqP8J+yBAAf7N/2uL69rBU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -2629,6 +2631,11 @@ github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= +github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= @@ -2749,6 +2756,8 @@ github.com/google/go-pkcs11 v0.2.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMc github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -2853,8 +2862,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hymkor/go-multiline-ny v0.21.0 h1:dGxPlvL2dcJvT4jOSCfaeVvcSptbf5q+Z5F92Uc8l8g= -github.com/hymkor/go-multiline-ny v0.21.0/go.mod h1:zrkZWF1DAUsILQ1P8+MSrHOkOnndHl2mC6xki8xRrr8= +github.com/hymkor/go-multiline-ny v0.22.4 h1:Ag2rkBpDnr3jp+AHe1CuXSOQ4AQ8D0+gBNVf+BAX+/0= +github.com/hymkor/go-multiline-ny v0.22.4/go.mod h1:v2lqQooHVAO53WICAIbgTn74bQtzsg4tFn29d+7JPwY= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -2898,7 +2907,13 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= +github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= +github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipzXQH7L4c= +github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8= github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= @@ -2920,8 +2935,10 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -2959,8 +2976,12 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ngicks/go-iterator-helper v0.0.21 h1:34dorbGaeL7RgxdymHBqZeL+VLL3hUyAL9QdE5HZrRQ= github.com/ngicks/go-iterator-helper v0.0.21/go.mod h1:g++KxWVGEkOnIhXVvpNNOdn7ON57aOpfu80ccBvPVHI= -github.com/nyaosorg/go-readline-ny v1.9.1 h1:TpQyXaNWtCN0TCRgUbGLMN8rKoPiwh2Xq58UZkivHmk= -github.com/nyaosorg/go-readline-ny v1.9.1/go.mod h1:54AzdC//M5EzTWRdvUHv2ChuYgp58mRrStTlpxiCmT0= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/nyaosorg/go-readline-ny v1.14.1 h1:bWyXpR6jRaCXysx4bnioxk36+YjQ6dypHKMjHnzIXdk= +github.com/nyaosorg/go-readline-ny v1.14.1/go.mod h1:/BDf3/H/AScnvey4LoDws1bjTZDB76EE7uKnW2apoKU= +github.com/nyaosorg/go-ttyadapter v0.3.0 h1:/Y7+rGJ0LEcs+AExevwNmND2VJvvpBmgbMuCbntKq3c= +github.com/nyaosorg/go-ttyadapter v0.3.0/go.mod h1:w6ySb/Y8rpr0uIju4vN/TMRHC/6ayabORHmEVs6d/qE= github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 h1:ZCnkxe9GgWqqBxAk3cIKlQJuaqgOUF/nUtQs8flVTHM= github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= @@ -3003,6 +3024,7 @@ github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOA github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= diff --git a/internal/mycli/cli_readline.go b/internal/mycli/cli_readline.go index 363077a2..c35a4766 100644 --- a/internal/mycli/cli_readline.go +++ b/internal/mycli/cli_readline.go @@ -127,6 +127,19 @@ func initializeMultilineEditor(c *Cli) (*multiline.Editor, History, error) { return nil, nil, err } + if c.SystemVariables.EnableFuzzyFinder { + if keyCode, ok := keys.NameToCode[keys.NormalizeName(c.SystemVariables.FuzzyFinderKey)]; ok { + fuzzyCmd := &fuzzyFinderCommand{cli: c} + err = ed.BindKey(keyCode, fuzzyCmd) + if err != nil { + return nil, nil, err + } + } else { + slog.Warn("unknown key name for CLI_FUZZY_FINDER_KEY, fuzzy finder disabled", + "key", c.SystemVariables.FuzzyFinderKey) + } + } + history, err := setupHistory(ed, c.SystemVariables.HistoryFile) if err != nil { return nil, nil, err diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go new file mode 100644 index 00000000..6fdbb03e --- /dev/null +++ b/internal/mycli/fuzzy_finder.go @@ -0,0 +1,164 @@ +// Copyright 2026 apstndb +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mycli + +import ( + "context" + "log/slog" + "regexp" + "strings" + + "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" + "github.com/hymkor/go-multiline-ny" + "github.com/ktr0731/go-fuzzyfinder" + "github.com/nyaosorg/go-readline-ny" +) + +// fuzzyFinderCommand implements readline.Command for the fuzzy finder feature. +// It detects the current input context and launches a fuzzy finder with +// appropriate candidates. The selected value replaces the current argument +// (completion-style behavior). +type fuzzyFinderCommand struct { + editor *multiline.Editor + cli *Cli +} + +func (f *fuzzyFinderCommand) String() string { + return "FUZZY_FINDER" +} + +// SetEditor is called by go-multiline-ny's BindKey to inject the editor reference. +func (f *fuzzyFinderCommand) SetEditor(e *multiline.Editor) { + f.editor = e +} + +func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readline.Result { + input := strings.Join(f.editor.Lines(), "\n") + result := detectFuzzyContext(input) + if result.contextType == "" { + return readline.CONTINUE + } + + candidates, err := f.fetchCandidates(ctx, result.contextType) + if err != nil { + slog.Debug("fuzzy finder: failed to fetch candidates", "context", result.contextType, "err", err) + return readline.CONTINUE + } + if len(candidates) == 0 { + return readline.CONTINUE + } + + // Terminal handoff: move cursor below editor, run fzf, then restore + rewind := f.editor.GotoEndLine() + + opts := []fuzzyfinder.Option{} + if result.argPrefix != "" { + opts = append(opts, fuzzyfinder.WithQuery(result.argPrefix)) + } + + idx, err := fuzzyfinder.Find(candidates, func(i int) string { + return candidates[i] + }, opts...) + + rewind() + B.RepaintLastLine() + + if err != nil { + // User cancelled (Escape/Ctrl+C) or other error + return readline.CONTINUE + } + + selected := candidates[idx] + + // Replace the argument portion: delete from argStartPos to end of buffer, + // then insert the selected value. + bufLen := len(B.Buffer) + if result.argStartPos < bufLen { + B.Delete(result.argStartPos, bufLen-result.argStartPos) + } + B.Cursor = result.argStartPos + B.InsertAndRepaint(selected) + + return readline.CONTINUE +} + +// fuzzyContextType represents what kind of candidates to provide. +type fuzzyContextType = string + +const ( + fuzzyContextDatabase fuzzyContextType = "database" +) + +// fuzzyContextResult holds the detected context, the argument prefix typed so far, +// and the buffer position where the argument starts. +type fuzzyContextResult struct { + contextType fuzzyContextType + argPrefix string // partial argument already typed (used as initial fzf query) + argStartPos int // position in the current line buffer where the argument starts (in runes) +} + +// useContextRe matches "USE" followed by optional whitespace and captures any partial argument. +var useContextRe = regexp.MustCompile(`(?i)^\s*USE(\s+(\S*))?$`) + +// detectFuzzyContext analyzes the current editor buffer to determine +// what kind of fuzzy completion is appropriate. +func detectFuzzyContext(input string) fuzzyContextResult { + if m := useContextRe.FindStringSubmatch(input); m != nil { + argPrefix := m[2] // may be empty + // argStartPos: position after "USE " in runes. + // Find where the argument starts by locating USE + whitespace. + argStart := len([]rune(input)) - len([]rune(argPrefix)) + return fuzzyContextResult{ + contextType: fuzzyContextDatabase, + argPrefix: argPrefix, + argStartPos: argStart, + } + } + return fuzzyContextResult{} +} + +// fetchCandidates returns completion candidates for the given context type. +func (f *fuzzyFinderCommand) fetchCandidates(ctx context.Context, ctxType fuzzyContextType) ([]string, error) { + switch ctxType { + case fuzzyContextDatabase: + return f.fetchDatabaseCandidates(ctx) + default: + return nil, nil + } +} + +// fetchDatabaseCandidates lists databases from the current instance. +func (f *fuzzyFinderCommand) fetchDatabaseCandidates(ctx context.Context) ([]string, error) { + session := f.cli.SessionHandler.GetSession() + if session == nil || session.adminClient == nil { + return nil, nil + } + + dbIter := session.adminClient.ListDatabases(ctx, &databasepb.ListDatabasesRequest{ + Parent: session.InstancePath(), + }) + + var databases []string + for db, err := range dbIter.All() { + if err != nil { + return nil, err + } + matched := extractDatabaseRe.FindStringSubmatch(db.GetName()) + if len(matched) > 1 { + databases = append(databases, matched[1]) + } + } + return databases, nil +} diff --git a/internal/mycli/fuzzy_finder_test.go b/internal/mycli/fuzzy_finder_test.go new file mode 100644 index 00000000..993dd541 --- /dev/null +++ b/internal/mycli/fuzzy_finder_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 apstndb +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mycli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectFuzzyContext(t *testing.T) { + tests := []struct { + name string + input string + wantContextType fuzzyContextType + wantArgPrefix string + wantArgStartPos int + }{ + { + name: "USE with trailing space", + input: "USE ", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 4, + }, + { + name: "USE without space", + input: "USE", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 3, + }, + { + name: "USE with partial db name", + input: "USE apst", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "apst", + wantArgStartPos: 4, + }, + { + name: "USE with full db name", + input: "USE my-database", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "my-database", + wantArgStartPos: 4, + }, + { + name: "use lowercase", + input: "use db", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "db", + wantArgStartPos: 4, + }, + { + name: "USE with leading space", + input: " USE ", + wantContextType: fuzzyContextDatabase, + wantArgPrefix: "", + wantArgStartPos: 6, + }, + { + name: "SELECT query", + input: "SELECT 1", + wantContextType: "", + }, + { + name: "empty input", + input: "", + wantContextType: "", + }, + { + name: "SHOW DATABASES", + input: "SHOW DATABASES", + wantContextType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectFuzzyContext(tt.input) + assert.Equal(t, tt.wantContextType, got.contextType) + if tt.wantContextType != "" { + assert.Equal(t, tt.wantArgPrefix, got.argPrefix) + assert.Equal(t, tt.wantArgStartPos, got.argStartPos) + } + }) + } +} diff --git a/internal/mycli/main_test.go b/internal/mycli/main_test.go index bb363719..e8c6c8da 100644 --- a/internal/mycli/main_test.go +++ b/internal/mycli/main_test.go @@ -139,7 +139,9 @@ func Test_initializeSystemVariables(t *testing.T) { "p1": lo.Must(memefish.ParseExpr("", "'string_value'")), "p2": lo.Must(memefish.ParseType("", "FLOAT64")), }, - TablePreviewRows: 50, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + TablePreviewRows: 50, }, wantErr: false, }, @@ -173,6 +175,8 @@ func Test_initializeSystemVariables(t *testing.T) { ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, Params: make(map[string]ast.Node), ReadOnlyStaleness: lo.ToPtr(spanner.ReadTimestamp(lo.Must(time.Parse(time.RFC3339Nano, "2023-01-01T00:00:00Z")))), + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -214,6 +218,8 @@ func Test_initializeSystemVariables(t *testing.T) { ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, Params: make(map[string]ast.Node), StatementTimeout: lo.ToPtr(30 * time.Second), + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -239,6 +245,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -264,6 +272,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -288,6 +298,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -332,8 +344,10 @@ func Test_initializeSystemVariables(t *testing.T) { }, }, }, - CLIFormat: enums.DisplayModeTable, - TablePreviewRows: 50, + CLIFormat: enums.DisplayModeTable, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + TablePreviewRows: 50, }, wantErr: false, }, @@ -382,6 +396,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -409,6 +425,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -437,6 +455,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -462,6 +482,8 @@ func Test_initializeSystemVariables(t *testing.T) { CLIFormat: enums.DisplayModeTable, OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -485,6 +507,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "output_full.tmpl", // OutputTemplate: should be parsed from file, hard to compare directly ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -510,6 +534,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -535,6 +561,8 @@ func Test_initializeSystemVariables(t *testing.T) { OutputTemplateFile: "", OutputTemplate: defaultOutputFormat, ParsedAnalyzeColumns: DefaultParsedAnalyzeColumns, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, }, wantErr: false, @@ -638,6 +666,8 @@ func Test_newSystemVariablesWithDefaults(t *testing.T) { HistoryFile: defaultHistoryFile, VertexAIModel: defaultVertexAIModel, LogLevel: slog.LevelWarn, + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", TablePreviewRows: 50, Params: make(map[string]ast.Node), } diff --git a/internal/mycli/system_variables.go b/internal/mycli/system_variables.go index 22749587..0db0628d 100644 --- a/internal/mycli/system_variables.go +++ b/internal/mycli/system_variables.go @@ -150,6 +150,8 @@ type systemVariables struct { AsyncDDL bool // CLI_ASYNC_DDL SkipSystemCommand bool // CLI_SKIP_SYSTEM_COMMAND SkipColumnNames bool // CLI_SKIP_COLUMN_NAMES + EnableFuzzyFinder bool // CLI_ENABLE_FUZZY_FINDER + FuzzyFinderKey string // CLI_FUZZY_FINDER_KEY // Streaming output configuration StreamingMode enums.StreamingMode // CLI_STREAMING @@ -264,6 +266,10 @@ func newSystemVariablesWithDefaults() systemVariables { OutputTemplate: defaultOutputFormat, LogLevel: slog.LevelWarn, + // Interactive defaults + EnableFuzzyFinder: true, + FuzzyFinderKey: "C_T", + // Streaming defaults StreamingMode: enums.StreamingModeAuto, // Default to automatic selection based on format TablePreviewRows: 50, // Default to 50 rows - enough to fit on one screen while prioritizing proper table formatting diff --git a/internal/mycli/var_registry.go b/internal/mycli/var_registry.go index fc3ae90c..15b0702d 100644 --- a/internal/mycli/var_registry.go +++ b/internal/mycli/var_registry.go @@ -207,6 +207,10 @@ func (r *VarRegistry) registerAll() { "Controls whether system commands are disabled.")) r.Register("CLI_SKIP_COLUMN_NAMES", BoolVar(&sv.SkipColumnNames, "A boolean indicating whether to suppress column headers in output. The default is false.")) + r.Register("CLI_ENABLE_FUZZY_FINDER", BoolVar(&sv.EnableFuzzyFinder, + "Enable fuzzy finder. The default is true.")) + r.Register("CLI_FUZZY_FINDER_KEY", StringVar(&sv.FuzzyFinderKey, + "Key binding for fuzzy finder. Uses go-readline-ny key names (e.g., C_T, M_F, F1). The default is C_T (Ctrl+T).")) r.Register("CLI_STREAMING", StreamingModeVar(&sv.StreamingMode, "Controls streaming output mode: AUTO (format-dependent), TRUE (always stream), FALSE (never stream). Default is AUTO.")) From 4a451f4779942f0577c519b7e9d42a22d69e0e41 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:25:59 +0900 Subject: [PATCH 2/2] fix(fuzzy-finder): use current line buffer instead of full multiline text detectFuzzyContext now operates on B.String() (current line buffer) instead of joining all editor lines. This fixes incorrect argStartPos when the cursor is not on the first line of a multiline input. Co-Authored-By: Claude Opus 4.6 --- internal/mycli/fuzzy_finder.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/mycli/fuzzy_finder.go b/internal/mycli/fuzzy_finder.go index 6fdbb03e..e01092f0 100644 --- a/internal/mycli/fuzzy_finder.go +++ b/internal/mycli/fuzzy_finder.go @@ -18,7 +18,6 @@ import ( "context" "log/slog" "regexp" - "strings" "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" "github.com/hymkor/go-multiline-ny" @@ -45,7 +44,9 @@ func (f *fuzzyFinderCommand) SetEditor(e *multiline.Editor) { } func (f *fuzzyFinderCommand) Call(ctx context.Context, B *readline.Buffer) readline.Result { - input := strings.Join(f.editor.Lines(), "\n") + // Use current line buffer, not the full multiline text. + // B is the buffer for the current line only, so argStartPos must be relative to it. + input := B.String() result := detectFuzzyContext(input) if result.contextType == "" { return readline.CONTINUE