turbo-editors/turbo-gopublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-go.git
git clone ssh://git@rickub.com/turbo-editors/turbo-go.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

📦 Turbo Go 3d7798b · on v1.0.0 · k33g · 10h ago
summary.md · 209 lines · 49.8 KBmarkdown
Blame HistoryOpen raw

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, 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 <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.
  • 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 <script>, and the language of a Markdown fence. The boundaries are written down in docs/*/reference/languages.md.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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-0Alt-9, which are the way out of a full-screen program. The cost, accepted knowingly: F1F12 never reach a program inside a terminal, so htop's function-key menu is unreachable.
  • 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.
  • 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.
  • 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.
  • Every text mutation goes through buffer.ReplaceRange. Undo history, modified flag, revision counter and cursor are maintained there and nowhere else.
  • Undo merges runs of typing and of backspaces into one step. Cursor movement ends a run; typing and deleting never merge.
  • 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.
  • An unknown colour in a theme is a load error, not a silent fallback.
  • The language server is optional by construction: app.Language is a no-op when nothing is connected, so no other code checks for it.
  • 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.
  • A window tells its content whether it is focused (Window.SetActiveFocusable.SetFocused), which is what stops every open window drawing a cursor.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • Upward communication is by function field (OnChange, OnCursorMove, OnCompletionRequest, …), not by interface.
  • Dialogs are asynchronous: pushModal(dialog, onClose), settled after each event. There is no nested event loop.
  • 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.
  • 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.
  • 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.
  • 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.

Build, test, run

make install              # build + install onto PATH (scripts/install.sh)
make uninstall            # remove it again
make build                # → bin/turbo-go
make test                 # the whole suite; the single documented command
make check                # fmt + vet + test — what a commit should pass
make run FILE=main.go
go test -short ./...      # skips the test that starts a real gopls
go test ./internal/golang/

Quality gate, separate from the tests:

python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .

Reports land in .quality/; exit code 0 = pass.

State as of 2026-09-01

  • 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.
  • Quality gate: PASS. 0/0/0, complexity 37 — the number fell from 1592 because the code moved, not because anything was simplified.
  • Released as v0.2.2 at d64410c, which is exactly HEAD, with a release page on Codeberg. The first release built on the library.
  • 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.
  • Nothing about the editor's behaviour changed. The menus, the keys, the themes, the file formats and the environment variables are what they were.

State as of 2026-08-31 (second session)

  • Feature-complete against the original request, plus terminal windows. Editor, Go colouring, themes, LSP completion and F8 shell windows all implemented.
  • 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.
  • 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.
  • 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.
  • Project tree (ticket 0003) implemented on 2026-08-31 and merged into main as PR #4. New internal/filetree package; Window ▸ Project tree / F9.
  • Window frame boxes ([x] closes, [■]/[▬] maximises) and the Open-dialog OK fix were merged as PR #3.
  • Markdown, JavaScript, HTML and shell colouring (tickets 0009, 0013, 0014) implemented on 2026-08-31 and merged into main as PR #5.
  • Snippets (ticket 0006) implemented on 2026-08-31 and merged into main as PR #6.
  • 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.
  • 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 %.
  • Quality gate: PASS. 0 lint errors, 0 warnings, 0 code smells, total complexity 1592.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Traps worth knowing

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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-worksturbo-go-works).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • .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.

Release tooling

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.

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.

Not yet established

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • No CI. There is no pipeline configuration in the repository.

  • 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.

  • 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.

  • .tickets/ holds 20 issues, 00020021, 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.

  • 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.

  • 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.

  • 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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# 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 `<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.
- **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 `<script>`**, and **the language of a Markdown fence**. The boundaries are written down in `docs/*/reference/languages.md`.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **Every text mutation goes through `buffer.ReplaceRange`.** Undo history, modified flag, revision counter and cursor are maintained there and nowhere else.
- **Undo merges runs** of typing and of backspaces into one step. Cursor movement ends a run; typing and deleting never merge.
- **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.
- **An unknown colour in a theme is a load error**, not a silent fallback.
- **The language server is optional by construction**: `app.Language` is a no-op when nothing is connected, so no other code checks for it.
- **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.
- **A window tells its content whether it is focused** (`Window.SetActive``Focusable.SetFocused`), which is what stops every open window drawing a cursor.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **`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.
- **Upward communication is by function field** (`OnChange`, `OnCursorMove`, `OnCompletionRequest`, …), not by interface.
- **Dialogs are asynchronous**: `pushModal(dialog, onClose)`, settled after each event. There is no nested event loop.
- **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.
- **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.
- **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`.
- **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.

## Build, test, run

```bash
make install              # build + install onto PATH (scripts/install.sh)
make uninstall            # remove it again
make build                # → bin/turbo-go
make test                 # the whole suite; the single documented command
make check                # fmt + vet + test — what a commit should pass
make run FILE=main.go
go test -short ./...      # skips the test that starts a real gopls
go test ./internal/golang/
```

Quality gate, separate from the tests:

```bash
python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
```

Reports land in `.quality/`; exit code 0 = pass.

## State as of 2026-09-01

- **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.
- **Quality gate: PASS.** 0/0/0, complexity 37 — the number fell from 1592 because the code moved, not because anything was simplified.
- **Released as v0.2.2** at `d64410c`, which is exactly HEAD, with a release page on Codeberg. The first release built on the library.
- **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.
- **Nothing about the editor's behaviour changed.** The menus, the keys, the themes, the file formats and the environment variables are what they were.

## State as of 2026-08-31 (second session)

- **Feature-complete against the original request**, plus terminal windows. Editor, Go colouring, themes, LSP completion and `F8` shell windows all implemented.
- **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`.
- **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.
- **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.
- **Project tree (ticket 0003) implemented on 2026-08-31** and **merged into `main`** as PR #4. New `internal/filetree` package; `Window ▸ Project tree` / `F9`.
- **Window frame boxes** (`[x]` closes, `[■]`/`[▬]` maximises) and the **Open-dialog OK fix** were merged as PR #3.
- **Markdown, JavaScript, HTML and shell colouring (tickets 0009, 0013, 0014) implemented on 2026-08-31** and **merged into `main`** as PR #5.
- **Snippets (ticket 0006) implemented on 2026-08-31** and **merged into `main`** as PR #6.
- **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.
- **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 %.
- **Quality gate: PASS.** 0 lint errors, 0 warnings, 0 code smells, total complexity 1592.
- **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.
- **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.
- **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.
- **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.
- **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.
- **`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.

## Traps worth knowing

- **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.
- **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.
- **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.
- **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.
- **`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.
- **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.
- **`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.
- **`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.
- **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`).
- **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.
- **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.
- **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.
- **`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.
- **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.**
- **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.**
- **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.
- **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.
- **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.
- **`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.
- **`.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.

## Release tooling

`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.

`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`.

## Not yet established

- **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.


- **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.
- **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.
- **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.
- **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.
- **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.
- **No CI.** There is no pipeline configuration in the repository.
- **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.
- **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.
- **`.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.
- **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.
- **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.
- **`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.