# turbo-go — project summary *A snapshot of the present. No history here — that is `history.md`.* ## What this is A Turbo C-style editor for Go, written in Go: a full-screen terminal IDE with a menu bar, movable overlapping windows, modal dialogs, mouse support, Go syntax colouring, loadable TOML themes, completion from `gopls`, terminal windows running a real shell, per-project settings, a project tree, snippets, the go toolchain a menu away, and windows onto coding agents speaking the Agent Client Protocol. **Since 2026-09-01 it is a thin editor on top of [turbo-core](https://rickub.com/turbo-editors/turbo-core)**, the library every Turbo editor shares. What is in this repository is `main.go` and `internal/golang` — about four hundred lines. The other fourteen packages moved into the library, unchanged in behaviour. Module path `rickub.com/turbo-editors/turbo-go`. Go 1.26.5. Remote: `ssh://git@rickub.com/turbo-editors/turbo-go.git`. ## Architecture Two packages here; everything else is the library. ``` main → {turbo-core/app, turbo-core/profile, turbo-core/settings, turbo-core/theme, turbo-core/version, internal/golang, tcell} internal/golang → {turbo-core/profile, turbo-core/syntax} ``` | Package | What it holds | | --- | --- | | `main` | Flags, the terminal, and the wiring: register Go, build the profile, read the project's settings, hand them to `app.New`, start gopls in the module root, run the loop | | `internal/golang` | The whole of what makes this Turbo Go: the profile (`golang.go`), the Go scanner on top of `go/scanner` (`scan.go`), and the four starter files a project gets (`templates.go`) | turbo-core holds `app`, `buffer`, `editor`, `filetree`, `lsp`, `profile`, `projectfile`, `settings`, `snippets`, `syntax`, `terminal`, `theme`, `tools`, `ui` and `version`. Its own `.memory/summary.md` is the place to read about them. `docs/diagrams/packages.drawio` is generated from `go list` and verified against it edge for edge. ## Decisions in force *The decisions below were made while this was a single program. Almost all of them are now enforced in turbo-core, where the code lives; they are kept here because this is where they were made and why they were made is recorded nowhere else. The ones about **this editor** come first.* - **This editor is a command, a profile and a scanner.** Everything else is turbo-core. `golang.Profile()` is the entire answer to "what makes this Turbo Go?" — the name, the slug, the `~G~o` menu, `go.mod` as the root marker, gopls with `serve`, and the three starter templates. Rejected: forking the editor for each language, which is two copies of eleven thousand lines drifting within a month. - **The Go scanner stays here, not in the library.** turbo-core colours the eight languages every editor meets whatever it is for — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles, shell. The language that *defines* an editor is registered by that editor, which is why a `.rs` file opens as plain text here. It is also the scanner least like the others: it goes through `go/scanner` and byte offsets, where every other one works a line at a time. - **`golang.Register()` is called from `main`, explicitly**, rather than from an `init` function, so that "this editor knows Go" is a line somebody can read. - **The environment variable names did not change.** `TURBO_GO_THEME_DIR` and `TURBO_GO_SNIPPET_DIR` are derived from the profile's slug precisely so a user who set one against a released binary is not broken by a refactoring. - **The version is a property of the build, not of the source.** There is **no version constant**: `internal/version` takes the number from the linker's stamp (`git describe --tags --dirty`, set by the Makefile and `scripts/install.sh`), then from `runtime/debug.ReadBuildInfo()`, then reports `unknown`. `unknown` is deliberately not a number — the failure being designed against is a plausible-looking version nobody set, which is exactly what `const Version = "0.1.0"` had become fourteen commits after somebody wrote it. Rejected: a `make release` target — releasing is three git commands and wrapping them hides which one failed. - **Anything that ships must be stamped explicitly.** Removing the version constant moved a cost that used to be invisible: an unstamped build used to carry the last number somebody typed, and now carries `devel`. In a **cross-compile** nothing else notices — the host binary is right while the five downloads are not. `02-build-releases.sh` therefore stamps every platform, from `make ldflags` rather than repeating the `-X` paths, and runs the staged binary for its own machine before declaring the release built. - **A release build stamps `TAG`, not `git describe`.** `02-build-releases.sh` overrides the Makefile's version — `make ldflags VERSION="${TAG}"` — so the binaries report what the release announces, by construction. An earlier attempt made the script *verify* that `git describe` agreed with `TAG` (exact-match tag, clean tree, correct report) and the user rejected it: the checks blocked the build for conditions that stamping the tag directly makes impossible. Building no longer needs the tag to exist at all; only `02-release.publish.sh` does. **Do not reintroduce those gates.** - **`-version` is written for a person, and scripts must not parse fields out of it.** `awk '{print $NF}'` read the build timestamp and failed a release the day the line grew a parenthetical. The release script now asks git directly (`git describe --tags --exact-match`, `git diff --quiet HEAD`) and uses `grep -F` for the binary, so its checks do not depend on the shape of the sentence. - **Two limits of the Go build system shape that design.** It does **not read git tags**, so a plain `go build .` can never report `0.1.0-14-g88a4c38`; it reports `devel` plus the commit, and the docs say so. And what it reports for such a build is a **pseudo-version** (`v0.1.1-0.20260831165958-88a4c3859bf3`), shown as `devel` instead because its `0.1.1` is a patch release that does not exist. `vcs.time` is deliberately unused: it is the *commit's* timestamp, so labelling it "Built" would be false on every binary. - **The About box omits a line whose fact is empty** rather than showing a blank one. A binary from `go install …@v0.2.0` knows its version and nothing else, and `Commit:` with nothing after it says only that the editor failed to fill it in. `aboutText(info, themeName)` is pure, so the box's text is tested without opening one. - **Eleven themes ship, and each states its whole palette.** `turbo-classic`, `turbo-dark`, `borland-light`, `cappuccino` (espresso brown), `catppuccin-frappe` and `catppuccin-latte` (the published palettes unchanged), `cobalt` (the recognised Cobalt palette, accents left loud) and the two monochromes (no hue at all, one on ink and one on paper). They live in turbo-core now. `Defines` is satisfied by inheritance, so a theme omitting a key silently shows a colour Turbo Classic chose for its navy background — unreadable on espresso or on black, and invisible to the completeness test. `TestEveryEmbeddedThemeSetsEveryKeyItself` closes that for shipped themes only; a **user** theme may still inherit, which is what `inherits` is for. - **Five rules hold a theme to being readable**, four of them arithmetic in `internal/editor` where the colour maths already lives: the cursor is ≥64 from its line and never a plain reversal of it, the current line is ≥16 from the page, and text meant to be read is ≥64 from its background. That last one **exempts the furniture** — desktop, shadow, scrollbar trough, inactive frame, disabled entry, gutter — which sits between 20 and 70 in every theme *by design*; a blanket rule would have flagged six correct keys in the two oldest themes. The floor 64 was chosen against a measured floor of 80 (turbo-dark's `syntax.comment`), so it catches a regression rather than the present. - **The fifth rule is the one no measurement finds**: two syntax classes a reader meets side by side must not be drawn identically. `turbo-classic` once painted `syntax.link` the same lime as `syntax.string`. Classes deliberately alike — string/char, constant/number, type/tag — are not grouped, so the test stays silent about them. `monochrome` passes it with no hue, using bold, italic and underline. - **A tool's command can ask for values.** A `{{label}}` in it opens a box before the command runs; the value is shell-quoted unless the label ends in `...`. The feature is turbo-core's — see its summary — and what belongs to this editor is the starter file's comments, which teach the syntax without adding a sixth tool. - **The go toolchain is data, not code.** `.turbo-go/tools.toml` holds the commands; the five Go defaults (`gofmt -l -w .`, `go vet ./...`, `go build ./...`, `go test ./...`, `go run .`) are the *contents of the starter file* that `Go ▸ Create tools file` writes, not compiled-in behaviour. `go vet` is the default linter only because it ships with the toolchain. Commands go to `sh -c`, so one entry can be a sequence. There is **no user-level tools file**, unlike snippets: a project's tools belong to its own toolchain, and a global one would offer `go build` in a Rust repository. - **Which menu a tool is in is the tool's choice too**, from a free-form `menu` key; absent means `Go`. A name nothing else uses simply creates a menu, between Go and Help, in the order the names first appear in the file. There is **no list of allowed names**, because a list would be a list of somebody else's projects. Rejected: a fixed second `Tools` menu (only moves the problem — a Tools menu holding Docker, psql and a deploy script is just as undifferentiated) and a separate `menus.toml` (two files that have to agree about which tools exist). `Go` stays **fixed** on the bar rather than becoming another name from the file, because it holds `Create tools file`, which has to be reachable in a project that has none. - **Hot keys for those menus are assigned by the editor, never read from the file.** The author of a tools file cannot know which letters are free, and a clash is **silent** — the bar answers the first menu matching a key, so the second draws normally and simply never opens. That trap already sprang once here (`Snippets` vs `Search`, with every test passing). `hotKeyLabel` marks the first letter of the name nothing else claims; tildes written into the name are kept when the letter is free and **dropped when it is not**, because refusing the file instead would break a working tools file the day a release adds a menu. Every letter taken means no hot key at all, which `F10` and the mouse still reach. - **The menu bar is rebuilt from a `stat`, not from a parse.** `Menu.OnOpen` refills one menu's items; the *set* of menus belongs to the bar, and a menu that does not exist yet has no `OnOpen` to call. `App.toolsStamp` holds the tools file's size and modification time, and one `stat` per turn of the loop decides whether to call `ui.MenuBar.SetMenus`. The stamp is taken *before* the bar is built, so a file written between the two is picked up next turn rather than missed. - **Where a command's output goes is the tool's choice**, from an `output` key: `popup` (the default), `terminal`, `editor`. An unknown value is **refused, not corrected** — `"termnial"` falling back silently would look as though it worked while sending the output elsewhere. Four of the five defaults are `popup`; `Run` is `terminal`, and is the worked example of why the key exists: a popup cannot answer a program that reads the keyboard, nor be stopped with `Ctrl-C`. - **The popup opens immediately and fills in**, rather than appearing when the command ends. A dialog arriving three seconds later swallows whatever was being typed at that moment. It is modal, which is a real cost on a slow build and is documented; Escape closes it *and* stops the command, which is the only way to interrupt one whose output is not in a terminal. - **The exit code is always in the popup's title**, and a finished command that printed nothing shows `(no output)`. `go build ./...` succeeding is silent, and a blank dialog with a neutral title cannot be told from one whose command has not started. While still running the body stays blank — "(no output)" is a verdict. - **`tools.Start` runs a command without a pty**, merging stderr into stdout in write order, capped at 10000 lines with `Dropped()` reporting the loss. Its `onLine` callback is a **parameter, not a field**, because it starts the goroutine that calls it — the same race `terminal.ViewOptions` was created to fix. - **The Code menu is turbo-core's, and so are its eight questions.** Describe symbol and Go to definition moved into it from Run and Search; their keys did not change. This repository documents the menu and owns none of it — as with everything else the two editors share, a change to it is a `/methodical-dev` cycle in turbo-core. - **The settings file a project creates turns autosave on.** A project that has gone to the trouble of having one has said what it wants, and the file is the visible, editable place to say otherwise. `settings.Default()` — what applies with no settings file at all — stays **off**: the editor must not write to disk in a directory somebody merely started it in. Two different statements, set in two different places on purpose. - **A workspace, not a `replace`, is how to build against an unreleased turbo-core.** `go work init . ../turbo-core` changes no tracked file, so there is nothing to forget before committing; `go.work` is gitignored in all three repositories. The commented-out `replace` at the bottom of `go.mod` still works and is documented as the older way, with its hazard named. - **The build runs the binary it just built and checks it names the right version.** `scripts/check-version.sh` is called by `make build`, by `scripts/install.sh` before the install, and by `02-build-releases.sh`. A linker stamp is a string and a wrong one is not an error — `-X` naming a symbol that does not exist links happily and stamps nothing — so nothing but running the binary catches it. The comparison is an **equality**: `0.2.0` is a substring of `10.2.0`. - **The installer replaces the binary by rename, never by `cp` over it.** macOS caches a binary's code signature against its **inode**; writing new bytes into the existing inode leaves the cached signature describing something else and the kernel refuses to execute a binary that built and installed cleanly. `cp` writes in place, so a *reinstall* failed while a first install worked. The temporary must sit in `$prefix`, because a rename only works within one filesystem. The install is atomic as a result, which is the same reasoning `internal/buffer` and `internal/projectfile` already follow. - **Stopping a command kills its whole process group**, not just the shell. A grandchild inherits the output pipe, so killing only the shell leaves the reading goroutine blocked until *that* ends — for `go test ./...` that is every test binary it spawned. `cmd.WaitDelay` is the backstop for anything that escapes the group. - **`App.tick` is the event loop's turn, extracted so a test can take one.** Everything in it is state-driven; tests call `tick`, never an individual step, or removing that step from the loop would leave them passing. - **A finished terminal view takes only the scrolling keys.** It used to consume every key and write it to a dead shell, where the write failed silently and the key was consumed anyway — so `Ctrl-W` could never close a finished window and the mouse was the only way out. - **Files a command rewrote are re-read, unless they have unsaved changes.** `Format` rewrites the file in front, and without this the next `F2` would write the unformatted version back over gofmt's work. A modified buffer is left alone and named on the status bar: the edit and the formatter genuinely disagree, and the editor is not in a position to decide. `Buffer.Reload` refuses over unsaved work by returning `ErrModified`, keeps the cursor (clamped), and discards the undo history. - **`terminal.ViewOptions` gives the callbacks *before* the goroutines start.** They were assignable fields, and `NewView` starts the goroutine that reads them — a data race that hid for a whole feature because a shell takes longer to produce output than an assignment takes to run. It surfaced the moment a command finished immediately. - **`ui.Menu` has one level of submenus**, via `MenuItem.Items`, and `Menu.OnOpen` refills a menu just before it drops down. One level because the only nested menu in the editor — snippets grouped by kind — is one level, and a general depth would mean replacing the bar's two indices with a path in the widget every dialog depends on. `OnOpen` exists because a menu built from a file, filtered by the front window, has no start-up moment at which its contents exist. - **A submenu panel flips left *and* is capped to the screen width.** Flipping alone cannot fit a panel wider than the terminal; long labels are clipped by the painter instead, because a frame with no right-hand edge looks broken in a way a truncated label does not. - **No two menus may share a hot key.** The bar answers the first match it finds, so a duplicate silently makes one menu unreachable. Snippets is `Alt-N`, not `Alt-S`, because Search already owns S — and `TestNoTwoMenusShareAHotKey` in `internal/app` is what holds it. - **Snippets come from two files, and the project's wins.** The user's `/turbo-go/snippets.toml` is read first, then `/.turbo-go/snippets.toml`; where a `group` **and** `name` clash the project's replaces it, being the more specific statement. A missing file is fine; a present-but-unreadable one is an error shown as a greyed line in the menu, because a silent drop looks exactly like having no snippets. - **A snippet is re-indented on insertion**, and it is one undo step. `editor.InsertSnippet` copies the current line's own whitespace prefix onto every line after the first — verbatim insertion restarts a multi-line body at column zero, which is wrong everywhere an `if err != nil` actually goes. Blank lines in a body stay blank, so no trailing whitespace lands in the next diff. Placeholders and tab stops were deliberately left out. - **The project tree is a window, not a docked panel.** A panel would mean `Desktop` growing a notion of reserved edges, and `fitInto`, the grow modes, maximising, tiling and cascading all having to respect them — a change to the foundation of the interface for one widget. As an ordinary window it gets F6, Alt-digits, `[x]`, `[■]` and Tile for free, and nothing in `ui` had to change. - **There is at most one project tree.** The root is fixed at start-up, so a second view of it would have nothing to distinguish it. `F9` on an open tree raises it, the way opening an already-open file does. - **The tree hides `.git` and nothing else** — deliberately *not* the Open dialog's rule of hiding every dot-entry. `.turbo-go/settings.toml` is a file the editor asks people to edit, and `.gitignore` and `.qlty/` belong to the project too. Respecting `.gitignore` as well was turned down for now: it needs a pattern engine (negation, `**`, anchoring) that is a feature in its own right. - **The tree does not watch the filesystem.** That would be `fsnotify`, a third dependency, for a feature whose failure mode is a stale line in a list. It re-reads on a save (the one moment the editor knows) and on `F5` / `Ctrl-R` (the moment only the user knows). `Refresh` re-reads only directories that were actually opened, so it costs what is on screen. - **The project is the working directory.** `.turbo-go/settings.toml` **and the project tree** are both rooted in `os.Getwd()` alone, with **no walk up** the way `go.mod` is found. A module has a real boundary; "the project" does not — it is where you chose to start. A walk would also make a file three directories up change your colours silently. Cost, accepted: starting the editor from `internal/app` means the project's theme does not apply. - **Theme precedence is flag > project file > built-in default.** `-theme`'s flag default is `""` rather than `theme.DefaultName` precisely so that "was it given?" is still answerable in `main.themeName`. - **`.turbo-go/` is created only by Options ▸ Create project settings**, never as a side effect. Writing it the first time someone picks a theme would put a directory into their repository for trying a colour. That is also what makes the write-back rule one sentence: the theme is written when the file exists, and not otherwise. - **`settings.SetTheme` rewrites one key in place, it never re-encodes the file.** Marshalling the struct back would be four lines and would delete every comment — in a file that exists to be hand-edited, and whose created form is mostly comments. This is why TOML colouring exists at all. - **Autosave is state checked at the top of the event loop, nudged by a `time.AfterFunc`.** Third instance of the same rule (see the re-announcement below and the terminal's redraws): `PostEvent` drops what does not fit, so the timer may only *cause a turn*, never decide. A failed save clears the deadline **before** writing, so a read-only file is retried once per edit rather than forever, and reports on the status bar rather than in a modal that would return every two seconds. - **One autosave deadline for the whole editor**, not one per window: "you stopped typing" is a single event, and a per-window deadline would save the file you moved away from at a different moment for no observable gain. - **The tree needed theme keys of its own; the terminal's reasoning does not apply, but the outcome is the same.** `list.selected` is coloured against a *dialog* — in turbo-classic it is white on navy while `window.body` is navy, so a tree borrowing it would have highlighted its selected row in the colour underneath it. `tree.text`, `tree.directory`, `tree.selected` and `tree.unfocused` exist for that, and a test holds every shipped theme to 64 channel values between the first and the third. - **Six languages, six hand-written scanners, and no general engine.** Go goes through `go/scanner`; TOML, Markdown, JavaScript, HTML and shell each have a file of ordinary Go sharing only `lineScanner` (a line in runes, a position, the spans so far). There is no pattern language and no grammar format on purpose: adding a language means writing one beside the others rather than learning a notation. - **A scanner guesses nothing.** Where a construct cannot be recognised from what one line holds, it is left alone rather than approximated — a highlighter that is wrong is worse than one that is quiet. Deliberately absent, each for a stated reason: **JavaScript regex literals** (telling `/x/g` from a division needs the previous token's type; a wrong guess strings the rest of the line), **shell heredocs**, **JavaScript inside `