| 📦 Turbo Go 3d7798b k33g 10h ago | 1 | # turbo-go — project summary |
| 2 | |
| 3 | *A snapshot of the present. No history here — that is `history.md`.* |
| 4 | |
| 5 | ## What this is |
| 6 | |
| 7 | 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. |
| 8 | |
| 9 | **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. |
| 10 | |
| 11 | Module path `rickub.com/turbo-editors/turbo-go`. Go 1.26.5. Remote: `ssh://git@rickub.com/turbo-editors/turbo-go.git`. |
| 12 | |
| 13 | ## Architecture |
| 14 | |
| 15 | Two packages here; everything else is the library. |
| 16 | |
| 17 | ``` |
| 18 | main → {turbo-core/app, turbo-core/profile, turbo-core/settings, turbo-core/theme, |
| 19 | turbo-core/version, internal/golang, tcell} |
| 20 | internal/golang → {turbo-core/profile, turbo-core/syntax} |
| 21 | ``` |
| 22 | |
| 23 | | Package | What it holds | |
| 24 | | --- | --- | |
| 25 | | `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 | |
| 26 | | `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`) | |
| 27 | |
| 28 | 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. |
| 29 | |
| 30 | `docs/diagrams/packages.drawio` is generated from `go list` and verified against it edge for edge. |
| 31 | |
| 32 | ## Decisions in force |
| 33 | |
| 34 | *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.* |
| 35 | |
| 36 | - **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. |
| 37 | - **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. |
| 38 | - **`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. |
| 39 | - **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. |
| 40 | - **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. |
| 41 | - **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. |
| 42 | - **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.** |
| 43 | - **`-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. |
| 44 | - **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. |
| 45 | - **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. |
| 46 | - **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. |
| 47 | - **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. |
| 48 | - **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. |
| 49 | - **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. |
| 50 | - **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. |
| 51 | - **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. |
| 52 | - **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. |
| 53 | - **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. |
| 54 | - **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`. |
| 55 | - **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. |
| 56 | - **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. |
| 57 | - **`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. |
| 58 | - **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. |
| 59 | - **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. |
| 60 | - **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. |
| 61 | - **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`. |
| 62 | - **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. |
| 63 | - **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. |
| 64 | - **`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. |
| 65 | - **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. |
| 66 | - **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. |
| 67 | - **`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. |
| 68 | - **`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. |
| 69 | - **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. |
| 70 | - **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. |
| 71 | - **Snippets come from two files, and the project's wins.** The user's `<config>/turbo-go/snippets.toml` is read first, then `<project>/.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. |
| 72 | - **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. |
| 73 | - **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. |
| 74 | - **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. |
| 75 | - **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. |
| 76 | - **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. |
| 77 | - **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. |
| 78 | - **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`. |
| 79 | - **`.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. |
| 80 | - **`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. |
| 81 | - **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. |
| 82 | - **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. |
| 83 | - **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. |
| 84 | - **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. |
| 85 | - **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 `<script>`**, and **the language of a Markdown fence**. The boundaries are written down in `docs/*/reference/languages.md`. |
| 86 | - **TOML, JavaScript and shell add no theme keys**; the markup languages needed five. `syntax.heading`, `syntax.tag`, `syntax.attribute`, `syntax.emphasis` and `syntax.link` have no Go equivalent — a heading is not a keyword, and a theme wanting quiet headings with loud keywords could not say so otherwise. Third-party themes that set none of them fall back to `default`: readable, undifferentiated. |
| 87 | - **A file is recognised by extension, then by shebang.** `LanguageOf(path, firstLine)`. The extension always wins; a file with none is a shell script when its first line names `sh`, `bash`, `zsh`, `dash` or `ksh`. That is what colours `configure` and a git hook. |
| 88 | - **Two direct dependencies only**: `tcell/v2` and `BurntSushi/toml`. `golang.org/x/sys` is now also direct, for the pseudo-terminal ioctls — it was already in the graph, indirect via tcell, so nothing new entered `go.sum`. The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written on purpose. Do not add another without saying why. |
| 89 | - **The terminal emulator is deliberately partial.** It implements what a shell, `go test`, `git`, `less`, `htop` and `vim` need — movement, erase, insert/delete, scroll region, SGR in all three colour depths, alternate screen, DECAWM, DECTCEM, DECCKM — and nothing else. Mouse reporting, bracketed paste, character sets, sixel and the DEC status reports are absent; a program asking for one gets silence rather than corruption. The boundary is written down in `docs/*/reference/terminal.md`; keep it true if you extend the parser. |
| 90 | - **A focused terminal outranks the editor's global shortcuts.** `App.keyLayers()` is the routing chain, and a terminal sits *above* the shortcuts — a shell needs `Ctrl-C`, `Ctrl-W` and `Ctrl-F`, all of which the editor would otherwise take. `editorOwnedKey` reserves only the function keys, `Alt-X` and `Alt-0`…`Alt-9`, which are the way out of a full-screen program. The cost, accepted knowingly: `F1`…`F12` never reach a program inside a terminal, so `htop`'s function-key menu is unreachable. |
| 91 | - **Closing a terminal asks nothing.** A terminal holds a running process, not unsaved work. `Quit` closes every terminal, because a window is the only handle on those shells. |
| 92 | - **Terminal redraws are on a 16 ms ticker, not per chunk of output.** Same reasoning as the re-announcement below: `PostEvent` drops what does not fit, and a build is exactly when the queue is fullest. A dropped tick cannot strand anything. |
| 93 | - **Widget bounds are absolute screen coordinates.** Hit-testing is a rectangle test; containers place children in screen space. `Painter.Sub` takes absolute coordinates while drawing calls take local ones — that asymmetry is deliberate and documented. |
| 94 | - **Every text mutation goes through `buffer.ReplaceRange`.** Undo history, modified flag, revision counter and cursor are maintained there and nowhere else. |
| 95 | - **Undo merges runs** of typing and of backspaces into one step. Cursor movement ends a run; typing and deleting never merge. |
| 96 | - **A new buffer's text is `""`, not `"\n"`.** A file gets its trailing newline when the user presses Enter. Line endings and the trailing newline of a file that was read are preserved byte for byte. |
| 97 | - **An unknown colour in a theme is a load error**, not a silent fallback. |
| 98 | - **The language server is optional by construction**: `app.Language` is a no-op when nothing is connected, so no other code checks for it. |
| 99 | - **The cursor is marked twice.** `editor.cursor`'s background is sent to the terminal through `SetCursorStyle(SteadyBlock, colour)` — DECSCUSR plus OSC 12 — and the cell underneath is painted in the same style as a fallback. Painting alone is not enough: the terminal draws its cursor *over* the cell in the user's own colour, so on a dark theme it covers whatever is beneath it. The colours must be a **distinct pair**, never a reversal of the line, and tests hold every theme to a minimum channel distance: 64 for the cursor against its line, 16 for the line against the page. Only the active window shows one. |
| 100 | - **A window tells its content whether it is focused** (`Window.SetActive` → `Focusable.SetFocused`), which is what stops every open window drawing a cursor. |
| 101 | - **The frame carries two boxes, and each says what pressing it will do.** `[x]` at the left closes; `[■]` at the right fills the desktop and then reads `[▬]`. A fixed symbol would be ambiguous exactly when it matters — you can see the window is large, not whether the box will enlarge it further or put it back. `Desktop.ToggleMaximize` is the single path, used by both the box and **Window ▸ Maximise**, so the two cannot disagree. A window not on a desktop has no `OnMaximize` and draws **no box** rather than a dead one. |
| 102 | - **A maximised window carries its restore rectangle through a terminal resize** (`Window.followDesktop`), and `Tile`/`Cascade` clear the maximised flag (`Window.place`). Without the first, shrinking the terminal leaves a window restoring to somewhere unreachable; without the second, a tiled window offers to restore to a rectangle that means nothing. |
| 103 | - **The re-announcement is state-driven, never event-driven.** `App.announceOpenDocuments` runs on every turn of the event loop and does nothing until the server is ready. A posted event would not do: tcell's queue is bounded, `PostEvent` drops what does not fit, and start-up — when gopls publishes diagnostics for the whole module — is when it is fullest. Correctness must not depend on a message allowed to go missing. |
| 104 | - **Documents already open are re-announced once the language server is ready.** `main` opens files *before* starting gopls, so the first `didOpen` reaches nothing. Without the second announcement, `didChange` arrives for a document the server was never told was open, gopls ignores it, and completion answers from the stale on-disk text. |
| 105 | - **Windows follow the terminal, they do not scale with it.** `ui.Window` has a Turbo Vision-style grow mode; a document window follows the desktop's right and bottom edges, so its far corner moves by exactly the delta the terminal's did and its top-left corner stays put. Proportional scaling was rejected: it moves windows the user placed on purpose, and rounding makes shrink-then-grow lossy. No window may exceed the desktop's own size. |
| 106 | - **Colour contrast is measured, not eyeballed.** `channelDistance` in `internal/editor` is the yardstick; turbo-dark once highlighted the cursor's line ten channel values from the page, which is no highlight at all. |
| 107 | - **`Dialog.MoveTo` / `CenterIn` move a dialog's controls with it.** Controls are placed in screen coordinates when the dialog is built, so moving the frame alone leaves them behind. A resize re-centres open dialogs and dismisses the completion popup, which is anchored to a cursor that has moved. |
| 108 | - **Upward communication is by function field** (`OnChange`, `OnCursorMove`, `OnCompletionRequest`, …), not by interface. |
| 109 | - **Dialogs are asynchronous**: `pushModal(dialog, onClose)`, settled after each event. There is no nested event loop. |
| 110 | - **In a dialog, arrows reach the focused control before they move the focus.** Reversing this makes every list box unusable by keyboard — it was a real bug, fixed and covered by tests. |
| 111 | - **In the Open / Save As box, the Name field mirrors the list highlight.** `ListBox.OnSelect` writes the highlighted entry into the field, and `confirm` falls back to the highlight when the field is empty. Without the wiring the two controls are independent and **OK does nothing at all** on a freshly opened dialog: the user has highlighted a file, the field is still empty, `Path()` returns `""`, and the button looks broken. Reported by the user, fixed 2026-08-31. |
| 112 | - **Agent windows are turbo-core's, and what belongs here is the starter file.** `acp.toml.tmpl` is the fourth embedded template, and `profile.Templates.Agents` is the whole of turbo-go's contribution to the feature — the example agent is `docker agent serve acp .turbo-go/agent.yaml`, which is a choice about what a Go developer is likely to have installed, not about the protocol. Every other editor gets agent windows by writing a starter file of its own and nothing else. The reasoning, and why it could not have been built here, is in `docs/*/explanation/agent-windows.md`. |
| 113 | - **The starter agents file teaches the window's keyboard as well as the format.** `Enter`, `Alt-Enter`, `Tab`, `Esc` and `Ctrl-W` are all in its comments, because a file the editor hands you is the one document a user is guaranteed to see. |
| 114 | |
| 115 | ## Build, test, run |
| 116 | |
| 117 | ```bash |
| 118 | make install # build + install onto PATH (scripts/install.sh) |
| 119 | make uninstall # remove it again |
| 120 | make build # → bin/turbo-go |
| 121 | make test # the whole suite; the single documented command |
| 122 | make check # fmt + vet + test — what a commit should pass |
| 123 | make run FILE=main.go |
| 124 | go test -short ./... # skips the test that starts a real gopls |
| 125 | go test ./internal/golang/ |
| 126 | ``` |
| 127 | |
| 128 | Quality gate, separate from the tests: |
| 129 | |
| 130 | ```bash |
| 131 | python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . |
| 132 | ``` |
| 133 | |
| 134 | Reports land in `.quality/`; exit code 0 = pass. |
| 135 | |
| 136 | ## State as of 2026-09-01 |
| 137 | |
| 138 | - **Migrated onto turbo-core.** `internal/*` is gone; `main.go` and `internal/golang` remain. The full existing test suite passes unchanged, plus the two real-gopls tests that came back here with the language they are about. |
| 139 | - **Quality gate: PASS.** 0/0/0, complexity 37 — the number fell from 1592 because the code moved, not because anything was simplified. |
| 140 | - **Released as v0.2.2** at `d64410c`, which is exactly HEAD, with a release page on Codeberg. The first release built on the library. |
| 141 | - **Depends on `turbo-core v0.2.0`**, with no active `replace`. On `main` that is v0.1.0 and builds from the module proxy. On `feature/more-syntaxes` the `require` names **v0.2.0, which is not published yet**: that branch does not build until turbo-core is tagged and released, and `go.sum` has no entry for it. The old replace block is still there, commented out, as the documented way to develop across the three repositories. |
| 142 | - **Nothing about the editor's behaviour changed.** The menus, the keys, the themes, the file formats and the environment variables are what they were. |
| 143 | |
| 144 | ## State as of 2026-08-31 (second session) |
| 145 | |
| 146 | - **Feature-complete against the original request**, plus terminal windows. Editor, Go colouring, themes, LSP completion and `F8` shell windows all implemented. |
| 147 | - **Terminal windows (ticket 0007) implemented on 2026-08-31**, and **merged into `main`** by the user as PR #1. New `internal/terminal` package — pseudo-terminal, VT/ANSI emulator, view widget — wired into `app` as `Window ▸ New terminal` / `F8`. |
| 148 | - **Project settings (ticket 0002) implemented on 2026-08-31** and **merged into `main`** as PR #2. New `internal/settings` package; TOML colouring in `internal/syntax`; autosave in `internal/app`; `Options ▸ Create project settings` and `Options ▸ Project settings…`. Both tickets have since been closed by the user. |
| 149 | - **Run in a real terminal once**, by the user, which found two defects — an invisible cursor under `turbo-dark`, and completion returning nothing. Both fixed and covered; see the handoff of the same date. |
| 150 | - **Project tree (ticket 0003) implemented on 2026-08-31** and **merged into `main`** as PR #4. New `internal/filetree` package; `Window ▸ Project tree` / `F9`. |
| 151 | - **Window frame boxes** (`[x]` closes, `[■]`/`[▬]` maximises) and the **Open-dialog OK fix** were merged as PR #3. |
| 152 | - **Markdown, JavaScript, HTML and shell colouring (tickets 0009, 0013, 0014) implemented on 2026-08-31** and **merged into `main`** as PR #5. |
| 153 | - **Snippets (ticket 0006) implemented on 2026-08-31** and **merged into `main`** as PR #6. |
| 154 | - **The Go menu (ticket 0017) implemented on 2026-08-31** and **merged into `main`** as PR #7, from `feature/go-format-lint`. New `internal/tools` and `internal/projectfile`; `terminal.Options.Args` and `terminal.ViewOptions`; `Buffer.Reload`; a `Go` menu on `Alt-G`; three output destinations; a `menu` key putting tools into menus of their own; the `Screen.Resize` fix. The user **closed ticket 0017** on 2026-08-31 without the `go mod init + touch main.go` idea it also mentioned, so that idea is settled rather than outstanding. |
| 155 | - **Tests**: every package green, and green under `-race`. Coverage — version 98.3 %, editor 96.2 %, syntax 96.0 %, terminal 95.9 %, buffer 95.8 %, tools 95.7 %, filetree 94.7 %, theme 94.3 %, settings 93.8 %, ui 93.5 %, snippets 88.9 %, lsp 87.0 %, app 86.3 %, projectfile 75.0 %, main 31.7 %. |
| 156 | - **Quality gate: PASS.** 0 lint errors, 0 warnings, 0 code smells, total complexity 1592. |
| 157 | - **Project settings verified end to end against the real binary in a pty**: `settings.toml` picking `turbo-dark` (seen as `ESC]12;#ffd787` on the wire), `-theme turbo-classic` overriding it, and autosave writing a file **from the idle timer alone** — the editor was killed without ever quitting cleanly, so no close-or-quit path could have written it. The control run, with no settings file, left the file untouched. |
| 158 | - **Verified against a real gopls 0.23.0**, twice over: `internal/lsp` drives the protocol directly, and `internal/app` replays the command's own start-up order and completes text that exists **only in the buffer** — which is the only version of that test that can fail. |
| 159 | - **Docs**: 31 pages × EN + FR under `docs/`, plus a per-package `README.md` and the drawio diagram. The diagram was checked against `go list` programmatically and matches edge for edge. |
| 160 | - **Moved from Codeberg to Rickub on 2026-09-19.** Module path `rickub.com/turbo-editors/turbo-go`, depends on `rickub.com/turbo-editors/turbo-core v1.0.0` (the first turbo-core version published under that path; `v0.9.0` on the proxy still declares the Codeberg path and cannot be required as `rickub.com/…`). Every import, the Makefile's `VERSION_PKG`, `scripts/install.sh`, the README and the docs say `rickub.com`; `go.sum` verified against the proxy; `GOWORK=off make check` green. The repository on this side is a fresh `git init` with `origin` at `ssh://git@rickub.com/turbo-editors/turbo-go.git` and **no commit yet**; `01-release.tag.sh` makes the first one. |
| 161 | - **Releases are cut by one script and one workflow (since 2026-09-19).** `01-release.tag.sh` runs `make check`, refuses a tag taken locally or on origin, refuses a `replace` in `go.mod`, commits, pushes the branch, then tags and pushes the tag. That push starts `.github/workflows/release.yml`, which runs the suite, builds with `02-build-releases.sh` and publishes the release page with the binaries using the job's own `GITHUB_TOKEN`. `02-release.publish.sh` and `04-release.upload-binaries.sh` are gone — no personal token, no `turbo-go.token.env`. `release.env` (untracked) holds only `TAG` and `ABOUT`. **`01` had no `set -e`** once, so a `git tag` refusing an existing tag was skipped in silence and the following `git push` pushed the *old* tag — a release cut from a commit nobody meant; that guard is still there, and `release_test.go` now runs `01` for real against a throwaway clone. |
| 162 | - **`scripts/install.sh`** builds and installs onto the user's PATH, reporting the Go version, the destination, whether it is on PATH, and whether gopls is there. Thirteen tests in `install_test.go` drive it for real, including that a failed build leaves an existing installation untouched and that a reinstall **replaces** the binary rather than writing into it. |
| 163 | |
| 164 | ## Traps worth knowing |
| 165 | |
| 166 | - **LSP columns are UTF-16 code units**, editor columns are runes. Everything crossing that boundary goes through `RuneToUTF16` / `UTF16ToRune`. On ASCII the two agree, so a mistake here survives testing until a file has an accent in it. |
| 167 | - **gopls asks its client questions.** It requests `workspace/configuration` during start-up and waits for the reply; a client that ignores server-to-client requests hangs with no error. `Client.handleRequest` answers them. |
| 168 | - **An empty completion list is usually a package that does not compile.** gopls answers nothing at all — no error — for a package it cannot load. `App.noCompletionsReason` and `App.languageReport` turn that into a sentence naming the problem. |
| 169 | - **A test whose fixture already contains the text being completed proves nothing.** gopls answers from disk for anything it has not been told is open, so such a test passes whether or not the editor said a word. The text must be *typed* into the buffer. |
| 170 | - **`len()` on a string with box-drawing characters counts bytes.** `"[■]"` is 5 bytes and 3 columns — that was a real bug in the close box, and the same characters are now the maximise box. Count runes for anything that becomes a screen width; `boxWidth` and a test hold the two in step. |
| 171 | - **A `ListBox` callback that nobody wires is invisible.** `OnSelect` existed, worked, and was covered by its own test in `internal/ui` — and `FileDialog` never set it, so the field and the list drifted apart with every test still green. A callback with no caller is not a feature. |
| 172 | - **`strings.Index` on a drawn screen row gives a byte offset, not a column.** A row is full of `░` and `║` at three bytes each, so clicking at that offset lands about thirty columns right of the target. This cost a wrong diagnosis: a test failure that looked like a second bug in the app was the test's own arithmetic. |
| 173 | - **`drawNumber` runs after `drawTitleBar`, so the number always wins.** A test that only checks the furniture survived is therefore vacuous — it would pass with the title margin off by one, because the number is simply repainted over the title. What breaks is the *title*, which loses a character and reads `main.g7`. Assert on the cell **beside** the furniture, not on the furniture. |
| 174 | - **A pseudo-terminal echoes the command line.** A test that types `echo red` and waits for `red` passes *before* the shell has run anything. Wait for something only the output can produce — a colour, or a string the typed line spells differently (`echo turbo''-go-works` → `turbo-go-works`). |
| 175 | - **Drawing tests must not race the shell's startup output.** A test asserting on a screen cell while a live shell writes to it passes or fails by luck; one such test hid a real fault for a whole session. `terminal.newOfflineView` builds a `View` with no session behind it — `Draw` needs none — and is deterministic. |
| 176 | - **The terminal cursor is drawn over cell (0,0) of a fresh screen.** A drawing test that samples the top-left cell is sampling the cursor, not the text. |
| 177 | - **A settings key absent is not the same as a key set to its default.** `settings.file` uses pointer fields for exactly this: `autosave = false` written out on purpose and no `autosave` line must not be the same statement, or a future default flip would silently change existing projects. |
| 178 | - **`emit` dropping empty spans makes "patch the span afterwards" unsafe.** In the TOML scanner, fixing up `spans[len-1].Start` after an emit that produced nothing rewrote the *previous* span instead. It bit a second time in the JavaScript scanner, differently: `finishTemplate` called a helper that ran the position to the end of the line, then asked `takeRest` to colour "what is left", which was nothing — a whole line of a template literal came back uncoloured. **Pass a span's start in as a parameter.** Never derive it from the scanner's position after a helper has moved it. |
| 179 | - **A test that drives the step under test proves nothing.** Twice now: `waitForLoopTurn` called `a.reloadAfterTools()` itself, so the test passed with that step removed from the event loop — fixed by extracting `App.tick` and having tests take a whole loop turn. And `TestStopEndsWhatTheCommandStartedToo` killed the shell before it had forked, so it passed without the process-group fix — fixed by waiting for the child to print. **Verify a new test by breaking the code it covers.** |
| 180 | - **A goroutine started by a constructor may not have its callbacks assigned afterwards.** `terminal.NewView` started the reading goroutine and callers then set `OnChange`/`OnExit` — a data race that existed from the terminal feature onward and that `-race` never caught, because a shell takes longer to produce its first output than the assignment takes to run. It surfaced only when a command finished immediately (`sh -c "echo x"`). The fix is `ViewOptions`: everything the goroutines read is set before they start. **When adding a constructor that starts a goroutine, take its callbacks as parameters.** |
| 181 | - **Two menus sharing a hot key make one of them unreachable, silently.** `handleClosedKey` returns on the first match. `Alt-S` opened Search rather than Snippets and every test passed; it was found by driving the real binary. `TestNoTwoMenusShareAHotKey` now covers it. |
| 182 | - **The theme completeness test only really constrains `turbo-classic`.** `turbo-dark` and `borland-light` both `inherits = "turbo-classic"`, and inheritance is resolved at parse time into the theme's own map, so `Defines` is true for an inherited key. Deleting a key from a child theme passes; deleting it from `turbo-classic` fails. Check against the base theme when verifying that test. |
| 183 | - **Colours that clash are only visible on a screen.** `syntax.link` was set to lime in `turbo-classic` — the exact colour of `syntax.string`, making a Markdown link indistinguishable from an inline `code` span. Every test passed. It was caught by rendering the editor through the project's own VT emulator and reading back the foreground of each run. |
| 184 | - **`tcell.KeyCtrlC` is 67, not 3.** tcell reports control bytes as `KeyCtrlSpace + b`, and `KeyCtrlSpace` is 64. Code that tests `key < 0x20` to spot a control key silently matches nothing. |
| 185 | - **`.qlty/qlty.toml` excludes `kits/**`** with a comment saying exactly what that hides. It hides two real defects in the kit's own `quality_report.py`, which belong to the kit. Do not widen the pattern. |
| 186 | |
| 187 | ## Release tooling |
| 188 | |
| 189 | `01-release.tag.sh` and `02-build-releases.sh` in the repository root, plus `.github/workflows/release.yml`, modelled on turbo-core's. `release.env` carries `TAG` and `ABOUT` and is gitignored (`*.env`); there is no token file any more. |
| 190 | |
| 191 | `01` tags; the tag push starts the workflow. It exports `TURBO_GO_RELEASING=1` before `make check` so the tests that run `01` against a throwaway clone do not recurse; the workflow sets the same variable for its `go test` step. `02-build-releases.sh` cross-compiles for the platforms in its own `PLATFORMS` array and writes `release/${TAG}/` (binaries, `SHA256SUMS`, `README.md`); adding a target is one line and the checksums and README follow. It takes the tag as its first argument (what CI does, having no `release.env`), validates it as `vX.Y.Z[-pre]`, refuses a `replace` in `go.mod`, and starts from an empty `release/${TAG}/`. The workflow attaches `turbo-go-*`, `SHA256SUMS` and `README.md` with `softprops/action-gh-release@v2`, `fail_on_unmatched_files: true`, and writes release notes from the tag's message with the docs linked at that tag. Rickub's release API takes only the job's `GITHUB_TOKEN` (a personal token is refused), and its dispatch API fires every dispatchable workflow of a ref — hence `contents: write`, no `secrets.`, and no `workflow_dispatch`. |
| 192 | |
| 193 | ## Not yet established |
| 194 | |
| 195 | - **Agent windows work, and nobody has typed into one by hand.** The whole path was driven against a real `docker agent` v1.139.0 and a real llama.cpp (JetBrains Mellum2) by running the binary in a pty: the `Alt-A` menu, the window, a streamed reply, a shell tool call, the permission dialog answered, and a ```go fence coloured span by span read back off the wire. The mouse, `Tab` between the panes, resizing mid-turn and two agents side by side are all untried. Detail in the handoff of 2026-09-15. |
| 196 | |
| 197 | |
| 198 | - **Barely run in a real terminal.** A few sessions by the user, plus scripted pty runs here. **Verified on a real pty**: the menu bar via F10, Alt-F, Alt-E and the mouse; and live resize via SIGWINCH, where the window's frame measured 78, then 98, then 48 cells as the terminal went 80 → 100 → 50. Also verified on the wire: the cursor escapes, `ESC[2 q` and `ESC]12;<colour>`, and tcell's restoration of both on exit. Still **unverified**: mouse dragging and corner-resizing, and the light theme on an actual terminal emulator. |
| 199 | - **A reinstall on macOS was broken until 2026-08-31**, and the diagnosis was made from the symptom rather than reproduced: this sandbox is Linux. If `the installed binary does not run` ever returns, the installer now prints the system's own message above it — read that before theorising. |
| 200 | - **Windows and macOS are untested.** The code paths exist (drive letters in `lsp.PathToURI`, `os.UserConfigDir`) but have only been exercised on Linux/arm64. `internal/terminal/pty_darwin.go` in particular **compiles and passes `go vet` but has never been run** — its `TIOCPTYGRANT` / `TIOCPTYUNLK` / `TIOCPTYGNAME` path is unverified. Terminal windows are **not implemented at all on Windows**: `pty_other.go` returns `ErrUnsupported` and `F8` says so; ticket 0015 tracks the ConPTY port. |
| 201 | - **No performance measurement.** The syntax cache means a full re-scan per change rather than per redraw, but nothing has been profiled. Behaviour on a file of tens of thousands of lines is unknown. |
| 202 | - **Diagnostics are stored but barely shown.** `Language.Diagnostics` keeps them per file and the status bar shows the first error; there is no marker in the gutter or under the offending text. |
| 203 | - **No CI.** There is no pipeline configuration in the repository. |
| 204 | - **Terminal windows have never been used in a real terminal.** Everything about them was verified against a real pty here — a shell really runs, `ls` really lists — but nobody has yet opened one inside `turbo-go` on a physical terminal and run `vim` or `htop` in it. |
| 205 | - **The project tree has never been driven by a human.** It was verified by rendering the real binary through the project's own VT emulator — `F9` lists the project with `.git` hidden, `→` nests two levels, `Enter` opens a file into a third window — but nobody has yet clicked a row or scrolled it with a real mouse. |
| 206 | - **`.tickets/` holds 20 issues**, `0002`…`0021`, most with an empty `body`. Twelve are closed (`0002`, `0003`, `0004`, `0006`, `0007`, `0009`, `0013`, `0014`, `0017`, `0019`, `0020`, `0021`); eight remain open: `0005` wasm plugins, `0008` a mini agent view, `0010` a version number in About (**implemented**), `0011` a website, `0012` more themes (**implemented**), `0015` Windows support for terminal windows, `0016` no shadow on tiled windows, `0018` a core library extracted from Turbo Go. `0010` (a version number in About) was **implemented on 2026-08-31** and is the user's to close. **The user opens and closes these; do not edit them.** The schema also carries an `epic:` field on some tickets — `.tickets/epics.yaml` lists the epics. |
| 207 | - **Autosave has never been used for a whole working session.** It was verified end to end in a pty, but nobody has yet spent an hour editing with it on, which is where a save at an unwanted moment would show up. |
| 208 | - **The window boxes have not been clicked by a human.** Both were verified by rendering the real binary through the project's own VT emulator — the frame reads `[x] … 1═[■]`, Window ▸ Maximise flips it to `[▬]` and fills the terminal, and a second use restores it — but nobody has yet pressed either box with an actual mouse. |
| 209 | - **`ui.Menu` has no nested submenus.** `MenuItem` has no `Items` field, so the project-settings entries are two flat items under Options rather than the submenu originally asked for. Adding nesting is a `ui` change nobody has asked for yet. |