Skip to content

cloudmanic/spice-edit

Repository files navigation

SpiceEdit

An opinionated, mouse-first terminal code editor for SSH workflows.

SpiceEdit is a single-binary code editor that runs inside your terminal but behaves like a tiny VS Code: a file tree on the left, tabs across the top, syntax highlighting in the middle, a status bar at the bottom — and it's all driven by the mouse, not arcane keystrokes.

It's built for the workflow most "modern" terminal editors ignore: SSHing into a remote box from inside tmux / zellij, opening a project, clicking around files like a normal human, copying and pasting through your local clipboard, and getting back to work.

CleanShot 2026-04-29 at 23 30 21@2x

Why does this exist?

Vim and friends are wonderful if you've spent years memorizing them. Most terminal editors assume you have. SpiceEdit doesn't.

The goals, in order:

  1. Mouse-first. Click a file to open it. Click a tab to switch. Click-and-drag to select text. Scroll wheel actually scrolls. Drag the splitter to resize the sidebar. Right-click (or click the icon, or double-tap Esc) for the action menu.
  2. No hot-key archaeology. Save, save & close, quit — they all live in a centered modal you open with one gesture. No Ctrl+ shortcuts that fight tmux, your shell, or your terminal emulator.
  3. SSH-friendly. Copy uses OSC 52 escape sequences with a tmux passthrough wrapper, so highlighting text on a remote box still ends up in your local Mac clipboard.
  4. One static binary. No runtime, no plugin manager, no config directory full of YAML. Drop it on a server and run it.
  5. Looks reasonable. Tokyo Night-inspired palette out of the box, syntax highlighting via chroma (no CGO, no tree-sitter setup).

Features

  • VS Code-shaped layout — file tree on the left, tab bar across the top, editor in the middle, status bar at the bottom.
  • Mouse-driven everything — click to place cursor, drag to select, scroll wheel scrolls, double-click selects a word, drag past the edge to auto-scroll a selection.
  • Syntax highlighting for dozens of languages via Chroma.
  • Action menu opened with the icon, right-click, or double-tap Esc. Keyboard navigation works too — arrow keys + Enter.
  • Live file tree — auto-refreshes every 10 seconds so files added or removed from disk show up without you doing anything.
  • External change detection — if a file on disk changes underneath an open clean buffer, the editor reloads it; if your buffer is dirty, you get a heads-up; if the file is deleted, the tab is flagged once.
  • Toggleable, draggable sidebar — show/hide the file tree from the menu, or drag the splitter to resize it.
  • Clipboard over SSH — OSC 52, including a tmux passthrough so copy works from inside a tmux session on a remote host.
  • Format on save — opt-in per-project via .spiceedit/format.json with a first-run trust prompt so cloning a repo never silently executes its commands. See Format on save.
  • Single binary, no CGO — cross-compiled for macOS, Linux, and Windows on amd64 and arm64.
CleanShot 2026-04-29 at 23 32 22@2x

Install

macOS / Linux (Homebrew)

The Homebrew formula is published into this repo's Formula/ directory. Tap it by URL (no homebrew-* repo naming convention required), then install:

brew tap cloudmanic/spice-edit https://github.com/cloudmanic/spice-edit
brew install cloudmanic/spice-edit/spice-edit

Updating

When a new release ships, refresh the tap and upgrade:

brew update
brew upgrade cloudmanic/spice-edit/spice-edit

Uninstalling

brew uninstall cloudmanic/spice-edit/spice-edit
brew untap cloudmanic/spice-edit

Linux (one-line install script)

The simplest way to drop SpiceEdit onto a Linux box (or any macOS that isn't using Homebrew) is the install script:

curl -fsSL https://raw.githubusercontent.com/cloudmanic/spice-edit/main/install.sh | sh

It detects your OS / arch, downloads the matching archive from the latest GitHub Release, and drops the spiceedit binary into ~/.local/bin (or /usr/local/bin when ~/.local/bin isn't writable). Re-run the same command to upgrade — it always fetches the latest tagged release.

Override behaviour with environment variables:

# Pin to a specific release.
curl -fsSL https://raw.githubusercontent.com/cloudmanic/spice-edit/main/install.sh \
  | VERSION=v0.0.18 sh

# Install to a custom directory.
curl -fsSL https://raw.githubusercontent.com/cloudmanic/spice-edit/main/install.sh \
  | INSTALL_DIR=/opt/bin sh

The script is plain POSIX sh — it works on Alpine / BusyBox / any SSH target where you don't want to depend on bash. It only needs tar plus one of curl or wget.

Other platforms (manual binary install)

Pre-built binaries for Linux, macOS, and Windows (amd64 + arm64) are attached to every GitHub Release. Download the archive for your OS/arch, extract it, and drop the spiceedit binary somewhere on your $PATH.

From source

git clone https://github.com/cloudmanic/spice-edit.git
cd spice-edit
make install        # builds and installs to $GOPATH/bin

Usage

spiceedit              # opens the current directory
spiceedit ~/code/app   # opens a specific project root
spiceedit main.go      # opens a file (project root = its parent dir)
spiceedit new-file.go  # creates the file on first save (vim-style)
spiceedit --version    # print version and exit
spiceedit --help       # print short usage

Then:

  • Click a file in the tree to open it.
  • Click a tab to switch, click the × to close it.
  • Click (top-left), right-click anywhere, or double-tap Esc for the action menu — including New file, Rename, Delete.
  • If your terminal forwards Button3, right-click on a file or folder in the tree opens a per-item context menu (New File on folders, Rename, Delete). macOS Terminal + tmux often swallows right-click, so all of those actions also live in the main menu.
  • Drag the splitter between the sidebar and editor to resize.
  • Click and drag in the editor to select; drag past the top or bottom edge to auto-scroll the selection.

Hotkeys

SpiceEdit deliberately avoids Ctrl+-style shortcuts (they fight tmux, zellij, and the terminal itself — Ctrl+S is XOFF flow control on a real terminal). Instead, Esc is the leader key: tap Esc, then within half a second tap one of the letters below.

Combo Action
Esc Esc Open ≡ menu
Esc s Save
Esc u Undo
Esc r Redo
Esc w Close tab
Esc q Quit
Esc n New file
Esc t Toggle sidebar
Esc / Toggle line comment
Esc f Find in file
Esc p Find file in project

A lone Esc is harmless — if you don't follow it with a bound key within the window, your next keystroke goes to the editor as normal, so accidental Esc taps never swallow a real character.

Everything reachable by hotkey is also reachable from the menu — the hotkeys are just a faster path for the actions you reach for most.

Find in file

Esc f (or Find in file from the menu) opens a search bar above the status bar:

 Find: foo█                       3 of 12   Enter: next · Shift+Enter: prev · Esc: close
  • Type to search — matching is case-insensitive substring, results highlight live as you type.
  • Enter jumps to the next match (wraps at the end), Shift+Enter jumps to the previous one.
  • Esc closes the bar and clears the highlights — each Esc f opens a fresh search.
  • The active match is painted a brighter color than the rest, so you can pick out where you are in the result set.

There's no regex, whole-word, or case-sensitive toggle in v1 — the common case is "I know roughly what I'm looking for, take me there."

Find file in project

Esc p (or Find file in project from the menu) opens a fuzzy file finder over every non-ignored file in the project:

┌ Find file                                                    esc ┐
│  app.go                                              50/12345    │
│  internal/app/app.go                                             │
│  internal/app/app_test.go                                        │
│  internal/finder/score.go                                        │
│  ...                                                             │
└──────────────────────────────────────────────────────────────────┘
  • Type to fuzzy-match. The matcher prefers basename hits, consecutive matches, and word boundaries — typing tab finds tab.go before tabs/foo.go before notable.go.
  • / to move, Enter to open, Esc to dismiss. Mouse hover highlights, click opens.
  • Honours .gitignore automatically. The fast path uses git ls-files --cached --others --exclude-standard (so a 50k-file repo indexes in ~150ms); non-git projects fall back to a Go walker that still respects the project root's .gitignore.
  • Indexed in the background at startup so the modal opens with results already in hand. Refreshes on the same 10-second cadence as the file tree, plus immediately after any create/rename/delete inside the editor.
  • Only files are listed — no directories, no symlinked duplicates.

Custom actions (open remote files on your laptop)

Watch the walkthrough

📺 Custom actions walkthrough on YouTube

SpiceEdit can read user-defined shell-out actions from ~/.config/spiceedit/actions.json and prepend them to the action menu. Each action runs against the currently open file when you click it.

The use case this was built for: you SSH from your laptop into a remote box, edit a file there, and want to open it on your laptop — but neither Sixel nor the Kitty graphics protocol survive the trip through zellij/tmux. The trick is to bypass the terminal entirely and pipe the file back over a second SSH connection.

File location

~/.config/spiceedit/actions.json (or $XDG_CONFIG_HOME/spiceedit/actions.json when set). The file is optional — without it, the menu just shows the built-in actions.

Schema

{
  "actions": [
    {
      "label": "Open on Rager",
      "command": "scp \"$FILE\" rager:~/Downloads/ && ssh rager open \"~/Downloads/$FILENAME\""
    },
    {
      "label": "Open on Cascade",
      "command": "scp \"$FILE\" cascade:~/Downloads/ && ssh cascade open \"~/Downloads/$FILENAME\""
    }
  ]
}

Each entry needs:

  • label — the menu text (kept under ~30 chars; long labels clip inside the modal).
  • command — handed to sh -c with two env variables exported:
    • FILE — absolute path of the active tab's file
    • FILENAME — basename of the same file

$HOME and ~ gotcha for two-hop SSH: the command runs in a shell on the SpiceEdit host (the remote box you SSH'd into). So $HOME and ~ outside of ssh "..." quotes expand to that box's home directory, not your laptop's. To run something on your laptop, wrap the remote command in quotes: ssh rager "open ~/Downloads/$FILENAME"$FILENAME is expanded locally (you want that — it's a filename), but ~ is sent literally and rager's shell expands it on arrival.

The action only enables when there's a file open. Commands run in a background goroutine, so a slow scp or hanging ssh won't freeze the editor; success or failure flashes in the status bar when it finishes.

Debugging — every run is logged

Every custom-action invocation appends a record to ~/.local/state/spiceedit/actions.log (or $XDG_STATE_HOME/spiceedit/actions.log when set). One entry per run, human-readable, with the exact command, the env vars that were exported, the duration, and the combined stdout / stderr:

[2026-04-30T13:26:32-07:00] Open on Rager (1.234s) → ok
  command: scp "$FILE" rager:~/Downloads/ && ssh rager open "$HOME/Downloads/$FILENAME"
  FILE:     /Users/spicer/dev/foo/bar.txt
  FILENAME: bar.txt
  --- output ---
  --- end ---

[2026-04-30T13:27:01-07:00] Open on Cascade (0.521s) → exit status 1
  command: scp "$FILE" cascade:~/Downloads/ && ssh cascade open "$HOME/Downloads/$FILENAME"
  FILE:     /Users/spicer/dev/foo/bar.txt
  FILENAME: bar.txt
  --- output ---
  ssh: connect to host cascade port 22: Connection refused
  lost connection
  --- end ---

tail -f ~/.local/state/spiceedit/actions.log while you click around to watch entries roll in. There's no rotation — the file is one-line per run plus a few lines of output, so it grows slowly. Delete it whenever you want to start fresh.

The "open on my laptop" workflow

Both example actions assume rager and cascade are SSH host aliases in the remote machine's ~/.ssh/config that resolve back to your laptop. The simplest way to set that up:

  1. On your laptop, generate (or pick) an SSH key pair you'll dedicate to inbound connections from your remote work box.

  2. On your laptop, make sure Remote Login is enabled (System Settings → General → Sharing → Remote Login on macOS) and add the public key to ~/.ssh/authorized_keys.

  3. On the remote box, drop the matching private key into ~/.ssh/id_<name> and add a host alias:

    Host rager
      HostName your-laptop.example.com   # or a Tailscale / mesh hostname
      User your-mac-username
      IdentityFile ~/.ssh/id_rager
  4. Test it by hand from the remote: ssh rager echo hi. Once that works, SpiceEdit can drive it the same way.

If your laptop sits behind NAT, point HostName at a Tailscale / WireGuard / Cloudflare-tunnel address — anywhere the remote can reach the laptop directly. The action itself is just scp + ssh; it doesn't care how the network gets there.

Anything else sh can do

The schema is deliberately small. If you can write it on one shell line, you can put it in actions.json:

{ "label": "Send to ChatGPT", "command": "cat \"$FILE\" | pbcopy && open https://chat.openai.com/" }
{ "label": "Lint with eslint", "command": "cd $(dirname \"$FILE\") && eslint \"$FILENAME\"" }
{ "label": "Run formatter",    "command": "gofmt -w \"$FILE\"" }

Format on save

SpiceEdit can run a formatter on every save — gofmt, php-cs-fixer, prettier, anything you like — but the feature is off by default and only kicks in for projects that opt in by checking in a config file. Quick edits to a stranger's repo will never silently rewrite their files.

Setup

Create .spiceedit/format.json in your project root:

{
  "commands": {
    "go":  ["gofmt", "-w", "$FILE"],
    "php": ["php-cs-fixer", "fix", "$FILE", "--quiet"],
    "py":  ["ruff", "format", "$FILE"],
    "js":  ["prettier", "--write", "$FILE"],
    "ts":  ["prettier", "--write", "$FILE"]
  }
}
  • Keys are file extensions, without the leading dot.
  • Values are argv arrays — passed straight to execve, no shell, so there's no injection surface. (Use ["sh", "-c", "..."] if you genuinely need a shell.)
  • $FILE in any argument is replaced with the absolute path of the file being saved.

First save: trust prompt

The first time SpiceEdit would run a formatter from a new (or edited) .spiceedit/format.json, you get a Yes / No prompt:

Trust this project's formatter? Allow .spiceedit/format.json to run formatters on save?

Pick Yes once and SpiceEdit will run the configured formatters silently from then on. Pick No and it will never run them in this project — until the config file changes, at which point you'll be prompted again. The remembered answer (and the SHA-256 hash of the config it applies to) lives in ~/.config/spiceedit/format-trust.json.

The hash is the security trick: a teammate can't push a "v2" of the config that runs rm -rf — your editor will re-prompt the next time you save, because the file has changed since you trusted it.

What happens on save

  1. Save writes the file to disk first. A broken formatter never blocks the save.
  2. SpiceEdit looks up the file's extension in format.json. No match → done.
  3. The configured command runs in a goroutine. Slow formatters don't freeze the UI; you can keep typing.
  4. When the formatter finishes, SpiceEdit reloads the buffer — but only if you haven't typed anything since saving. If you did, your in-flight edits win and a status flash tells you the on-disk file was reformatted.
  5. If the configured binary isn't installed, it's a silent no-op. You don't have to install everyone's formatter to clone the repo.

Sharing vs. ignoring

Two reasonable patterns:

  • Commit .spiceedit/format.json so everyone on the team gets the same format-on-save behavior automatically.
  • Add .spiceedit/ to .gitignore if developers prefer their own setups — each person's local copy can configure whatever formatters they like.

Both work. SpiceEdit doesn't care which you pick.

Personal defaults — the install prompt

You can list your favorite formatters once globally in ~/.config/spiceedit/format-defaults.json (same shape as the project file):

{
  "commands": {
    "go":  ["gofmt", "-w", "$FILE"],
    "php": ["php-cs-fixer", "fix", "$FILE", "--quiet"],
    "py":  ["ruff", "format", "$FILE"]
  }
}

These never run on their own. Instead, when you save a file in a project where:

  1. The project's .spiceedit/format.json is missing or has no entry for that file's extension, and
  2. Your global defaults do have an entry for that extension,

…SpiceEdit asks once: "Add gofmt for .go to .spiceedit/format.json?"

  • Yes — merges the entry into the project's config (creating .spiceedit/format.json if it didn't exist), auto-trusts the resulting file, and runs the formatter on the save you just made.
  • No / Esc — remembered per-extension in the trust file. You won't be re-asked about that file type in this project until you manually edit the project config.

This keeps your personal preferences out of repos that don't want them while still making it one click to opt a project in.

Project layout

.
├── main.go                   # Entry point — parses optional rootDir arg
├── internal/
│   ├── app/                  # Event loop, layout, menu modal, splitter
│   ├── editor/               # Buffer, tab, cursor, syntax highlighting
│   ├── filetree/             # Lazy directory tree with identity-preserving refresh
│   ├── clipboard/            # OSC 52 clipboard with tmux passthrough
│   ├── customactions/        # Loader for ~/.config/spiceedit/actions.json
│   ├── format/               # Format-on-save config + trust store
│   ├── finder/               # Project file index + fuzzy matcher
│   ├── theme/                # Tokyo Night-inspired palette
│   └── version/              # Single-line version constant
├── .github/workflows/        # Auto-release pipeline
├── .goreleaser.yml           # Cross-compile + brew formula config
├── Formula/                  # Homebrew formula (written by CI)
└── Makefile

Development

make run          # build and run against the current directory
make build        # build to ./bin/spiceedit
make build-linux  # cross-compile a linux/amd64 binary
make test         # full suite with -race (same as CI)
make test-short   # quick iteration loop (-short, no race)
make coverage     # writes coverage.out + a browsable coverage.html
make tidy         # go mod tidy
make clean        # rm -rf bin + coverage artifacts

Every push and PR runs go test ./... on Linux + macOS via .github/workflows/test.yml. New code needs a corresponding _test.go — see CLAUDE.md for the bar.

Releases

Releases are fully automated. Every push to main:

  1. Reads internal/version/version.go.
  2. If that file was hand-edited in the pushed commit, the version is used as-is (this is how you bump major or minor: edit the constant manually). Otherwise the patch number is auto-bumped and committed back to main with [skip ci].
  3. Tags v<x.y.z> and pushes the tag.
  4. GoReleaser cross-compiles for linux/darwin/windows × amd64/arm64, attaches archives to a GitHub Release, and pushes an updated formula into Formula/spice-edit.rb on this same repo.

No PAT, no separate tap repo — the default workflow GITHUB_TOKEN is enough since the formula lives in the source repo.

License

MIT — see LICENSE.

Copyright © 2026 Cloudmanic, LLC.

About

An opinionated mouse-focused terminal editor.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors