Skip to main content

How I Finally Embraced Text Mode (or How I Use F# in Helix)

· 6 min read
Founder

I had been using helix for around a year before I finally switched from rider to helix for F# programming. Initially, I used helix only for editing custom text files without any Language Server Protocols (LSPs).

Then I discovered how easy it is to set up LSP for virtually any text format you can think of, such as docker compose, json, yaml, markdown, or even english grammar.

And that's not even mentioning LSPs for programming languages—I now wonder how I could have lived without them before.

helix

CLI tools I use

I use wezterm (eliminating the need for tmux), fish shell, lazygit, zoxide, mise, yazi, and helix as my entire development setup, allowing me to stay within the command line. I've only listed tools that are vital for my workflow—there are others I use as well, but I highly recommend checking out each of these if you're not familiar with them.

If you haven't heard about helix, you should read experiences on Reddit where long-term vim users have tried it and had very few reservations, mainly regarding slightly different keybindings. The major advantage is the out-of-box experience—features that require plugins in vim come built into helix.

These include bracket matching, fuzzy finder that mixes file/content search in a single expression, multi-cursor support, and LSP compatibility comparable to neovim.

My personal approach is to install the latest nodejs with mise and install language servers globally with pnpm (also managed by mise). This makes them accessible from any project without polluting individual projects with language servers that are necessary for editing but not for running, building, or compiling.

For F#, you only need fsautocomplete to get full F# IntelliSense and semantic code analysis for features like going to references/implementation or renaming an object globally in a project.

I really appreciate the simplicity of the helix setup, where the only things you need to configure are custom shortcuts and language servers.

LSP configuration

To give you an idea what language servers I use and how the configuration looks, you can check my own configuration below.

languages.toml
[language-server.angular-ls]
command = "ngserver"
args = [
"--stdio",
"--tsProbeLocations",
".",
"--ngProbeLocations",
".",
"--forceStrictTemplates",
]

[language-server.tailwind-ls]
command = "tailwindcss-language-server"
args = ["--stdio"]

[language-server.biome-ls]
# much faster than prettier but does not support html yet
command = "biome"
args = ["lsp-proxy"]

[language-server.typescript-ls]
command = "typescript-language-server"
args = [ "--stdio"]

[language-server.html-ls]
command = "vscode-html-language-server"
args = [ "--stdio"]

[language-server.json-ls]
command = "vscode-json-language-server"
args = ["--stdio"]
config = { "json" = { "validate" = { "enable" = true } } }

[language-server.css-ls]
command = "vscode-css-language-server"
args = [ "--stdio"]
config = { "css" = { "validate" = { "enable" = true } } }

[language-server.markdown-vs-ls]
command = "vscode-markdown-language-server"
args = ["--stdio"]

[language-server.marksman-ls]
command = "marksman"
args = []

[language-server.harper-ls]
command = "harper-ls"
args = ["--stdio"]
config = { harper-ls = { diagnosticSeverity = "warning" } }

[language-server.docker-ls]
command = "docker-langserver"
args = ["--stdio"]

[language-server.yaml-ls]
command = "yaml-language-server"
args = ["--stdio"]

[language-server.docker-compose-ls]
command = "docker-compose-langserver"
args = ["--stdio"]

[[language]]
name = "typescript"
auto-format = true
language-servers = [ { name = "typescript-ls", except-features = [ "format" ] }, { name = "angular-ls", except-features = ["format"]}, "biome-ls" ]

[[language]]
name = "html"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "html", "--print-width", "100", "--experimental-ternaries", "--bracket-same-line" ] }
language-servers = ["tailwind-ls", "angular-ls", "html-ls" ]

[[language]]
name = "json"
auto-format = true
language-servers = [ { name ="json-ls", except-features=["format"]}, "biome-ls" ]

[[language]]
name = "css"
auto-format = true
language-servers = [{name ="css-ls", except-features=["format"]}, "biome-ls"]

[[language]]
name = "scss"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "scss"] }
language-servers = ["css-ls"]

[[language]]
name = "markdown"
auto-format = true
formatter = { command = "prettier", args = ["--parser", "markdown", "--prose-wrap", "never"] }
language-servers = ["marksman-ls", "harper-ls"]

[[language]]
name = "dockerfile"
auto-format = true
language-servers = ["docker-ls"]

[[language]]
name = "docker-compose"
auto-format = true
language-servers = ["docker-compose-ls", "yaml-ls"]

[language-server.fsharp-ls]
command = "fsautocomplete"
args = ["--verbose"]
config = { AutomaticWorkspaceInit = true, FSharp = {ExternalAutocomplete = true}}

[[language]]
name = "fsharp"
scope="source.fs"
roots = ["fsproj", "sln", ".git"]
file-types = ["fs", "fsx", "fsi"]
language-servers = ["fsharp-ls"]
comment-token = "//"
indent = { tab-width = 4, unit = " " }
auto-format = true

[language-server.yaml-language-server]
command = "yaml-language-server"
args = ["--stdio"]

[language-server.fish-lsp]
command = "fish-lsp"
args = ["start"]
environment = { fish_lsp_show_client_popups = "false" }


[[language]]
name = "fish"
scope = "source.fish"
injection-regex = "fish"
file-types = ["fish"]
shebangs = ["fish"]
comment-token = "#"
language-servers = ["fish-lsp"]
indent = { tab-width = 4, unit = " " }
auto-format = true
formatter = { command = "fish_indent" }

Helix is done in rust and it's ultra fast to start-up, so you can use it to edit just one-off file in friendly environment but it also serves as an IDE.

The only thing I might miss is debugging, but the workflow forces you to become better programmer and instead of debugging use targeted unit tests to understand behaviour better.

Some of my favorite shortcuts include mi" or mi(", which select text inside quotes or parentheses (or any symbol you desire). I also use gw frequently, which highlights the first two letters of each word on screen. When you type any of those two letters, you jump to that precise word on the page, giving you the precision of a text surgeon.

Another great combination is the alt-o, alt-i pair to expand or decrease selection to parent/child syntax nodes. Think of it as selecting syntactically logical units, from a line to an expression to a function. This is particularly useful for selecting entire syntactic units in languages like html.

Teaser how my editor looks when editing a markdown file where I have grammar server configured can be seen below.

grammar

What about AI?

You might wonder how AI workflows fit into this setup. I use AI in a completely decoupled way from my editor workflow. Aider is an excellent command-line tool that allows you to use any AI provider—whether it's Anthropic, OpenAI, or even locally run open-source models (though I don't recommend the latter due to speed limitations, but if progress continues, there will be no need to change tools from aider, just its config).

The process is simple: run aider --watch-files in a directory, then type a comment ending with AI!. Aider processes this outside your editor but updates the files, so you just need to refresh the file to see the changes. I believe this offers the best of both worlds — integration with AI without being limited by the editor interface.

There's also ai lsp that you can use in helix for AI-powered IntelliSense, but so far I haven't felt the need to try it.

Below is an illustration of how I prompt AI from inside the editor, which has no awareness of the AI integration. I simply add a comment and save the file, and once aider completes its work, I refresh the file to see the updates. I can even add comments on multiple lines addressing different issues, and the LLM will consider all of them as a single input.

aider