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~omenu,go.modas the root marker, gopls withserve, 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
.rsfile opens as plain text here. It is also the scanner least like the others: it goes throughgo/scannerand byte offsets, where every other one works a line at a time. golang.Register()is called frommain, explicitly, rather than from aninitfunction, so that "this editor knows Go" is a line somebody can read.- The environment variable names did not change.
TURBO_GO_THEME_DIRandTURBO_GO_SNIPPET_DIRare 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/versiontakes the number from the linker's stamp (git describe --tags --dirty, set by the Makefile andscripts/install.sh), then fromruntime/debug.ReadBuildInfo(), then reportsunknown.unknownis deliberately not a number — the failure being designed against is a plausible-looking version nobody set, which is exactly whatconst Version = "0.1.0"had become fourteen commits after somebody wrote it. Rejected: amake releasetarget — 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.shtherefore stamps every platform, frommake ldflagsrather than repeating the-Xpaths, and runs the staged binary for its own machine before declaring the release built. - A release build stamps
TAG, notgit describe.02-build-releases.shoverrides 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 thatgit describeagreed withTAG(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; only02-release.publish.shdoes. Do not reintroduce those gates. -versionis 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 usesgrep -Ffor 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 report0.1.0-14-g88a4c38; it reportsdevelplus 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 asdevelinstead because its0.1.1is a patch release that does not exist.vcs.timeis 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.0knows its version and nothing else, andCommit: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-frappeandcatppuccin-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.Definesis 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.TestEveryEmbeddedThemeSetsEveryKeyItselfcloses that for shipped themes only; a user theme may still inherit, which is whatinheritsis for. - Five rules hold a theme to being readable, four of them arithmetic in
internal/editorwhere 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'ssyntax.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-classiconce paintedsyntax.linkthe same lime assyntax.string. Classes deliberately alike — string/char, constant/number, type/tag — are not grouped, so the test stays silent about them.monochromepasses 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.tomlholds the commands; the five Go defaults (gofmt -l -w .,go vet ./...,go build ./...,go test ./...,go run .) are the contents of the starter file thatGo ▸ Create tools filewrites, not compiled-in behaviour.go vetis the default linter only because it ships with the toolchain. Commands go tosh -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 offergo buildin a Rust repository. - Which menu a tool is in is the tool's choice too, from a free-form
menukey; absent meansGo. 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 secondToolsmenu (only moves the problem — a Tools menu holding Docker, psql and a deploy script is just as undifferentiated) and a separatemenus.toml(two files that have to agree about which tools exist).Gostays fixed on the bar rather than becoming another name from the file, because it holdsCreate 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 (
SnippetsvsSearch, with every test passing).hotKeyLabelmarks 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, whichF10and the mouse still reach. - The menu bar is rebuilt from a
stat, not from a parse.Menu.OnOpenrefills one menu's items; the set of menus belongs to the bar, and a menu that does not exist yet has noOnOpento call.App.toolsStampholds the tools file's size and modification time, and onestatper turn of the loop decides whether to callui.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
outputkey: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 arepopup;Runisterminal, and is the worked example of why the key exists: a popup cannot answer a program that reads the keyboard, nor be stopped withCtrl-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.Startruns a command without a pty, merging stderr into stdout in write order, capped at 10000 lines withDropped()reporting the loss. ItsonLinecallback is a parameter, not a field, because it starts the goroutine that calls it — the same raceterminal.ViewOptionswas 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-devcycle 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-corechanges no tracked file, so there is nothing to forget before committing;go.workis gitignored in all three repositories. The commented-outreplaceat the bottom ofgo.modstill 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.shis called bymake build, byscripts/install.shbefore the install, and by02-build-releases.sh. A linker stamp is a string and a wrong one is not an error —-Xnaming 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.0is a substring of10.2.0. - The installer replaces the binary by rename, never by
cpover 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.cpwrites 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 reasoninginternal/bufferandinternal/projectfilealready 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.WaitDelayis the backstop for anything that escapes the group. App.tickis the event loop's turn, extracted so a test can take one. Everything in it is state-driven; tests calltick, 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-Wcould 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.
Formatrewrites the file in front, and without this the nextF2would 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.Reloadrefuses over unsaved work by returningErrModified, keeps the cursor (clamped), and discards the undo history. terminal.ViewOptionsgives the callbacks before the goroutines start. They were assignable fields, andNewViewstarts 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.Menuhas one level of submenus, viaMenuItem.Items, andMenu.OnOpenrefills 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.OnOpenexists 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, notAlt-S, because Search already owns S — andTestNoTwoMenusShareAHotKeyininternal/appis what holds it. - Snippets come from two files, and the project's wins. The user's
<config>/turbo-go/snippets.tomlis read first, then<project>/.turbo-go/snippets.toml; where agroupandnameclash 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.InsertSnippetcopies 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 anif err != nilactually 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
Desktopgrowing a notion of reserved edges, andfitInto, 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 inuihad 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.
F9on an open tree raises it, the way opening an already-open file does. - The tree hides
.gitand nothing else — deliberately not the Open dialog's rule of hiding every dot-entry..turbo-go/settings.tomlis a file the editor asks people to edit, and.gitignoreand.qlty/belong to the project too. Respecting.gitignoreas 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 onF5/Ctrl-R(the moment only the user knows).Refreshre-reads only directories that were actually opened, so it costs what is on screen. - The project is the working directory.
.turbo-go/settings.tomland the project tree are both rooted inos.Getwd()alone, with no walk up the waygo.modis 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 frominternal/appmeans the project's theme does not apply. - Theme precedence is flag > project file > built-in default.
-theme's flag default is""rather thantheme.DefaultNameprecisely so that "was it given?" is still answerable inmain.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.SetThemerewrites 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):PostEventdrops 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.selectedis coloured against a dialog — in turbo-classic it is white on navy whilewindow.bodyis navy, so a tree borrowing it would have highlighted its selected row in the colour underneath it.tree.text,tree.directory,tree.selectedandtree.unfocusedexist 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 onlylineScanner(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/gfrom 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 indocs/*/reference/languages.md. - TOML, JavaScript and shell add no theme keys; the markup languages needed five.
syntax.heading,syntax.tag,syntax.attribute,syntax.emphasisandsyntax.linkhave 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 todefault: 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 namessh,bash,zsh,dashorksh. That is what coloursconfigureand a git hook. - Two direct dependencies only:
tcell/v2andBurntSushi/toml.golang.org/x/sysis now also direct, for the pseudo-terminal ioctls — it was already in the graph, indirect via tcell, so nothing new enteredgo.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,htopandvimneed — 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 indocs/*/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 needsCtrl-C,Ctrl-WandCtrl-F, all of which the editor would otherwise take.editorOwnedKeyreserves only the function keys,Alt-XandAlt-0…Alt-9, which are the way out of a full-screen program. The cost, accepted knowingly:F1…F12never reach a program inside a terminal, sohtop's function-key menu is unreachable. - Closing a terminal asks nothing. A terminal holds a running process, not unsaved work.
Quitcloses 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:
PostEventdrops 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.Subtakes 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.Languageis 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 throughSetCursorStyle(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.ToggleMaximizeis the single path, used by both the box and Window ▸ Maximise, so the two cannot disagree. A window not on a desktop has noOnMaximizeand draws no box rather than a dead one. - A maximised window carries its restore rectangle through a terminal resize (
Window.followDesktop), andTile/Cascadeclear 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.announceOpenDocumentsruns 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,PostEventdrops 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.
mainopens files before starting gopls, so the firstdidOpenreaches nothing. Without the second announcement,didChangearrives 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.Windowhas 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.
channelDistanceininternal/editoris the yardstick; turbo-dark once highlighted the cursor's line ten channel values from the page, which is no highlight at all. Dialog.MoveTo/CenterInmove 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.OnSelectwrites the highlighted entry into the field, andconfirmfalls 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.tmplis the fourth embedded template, andprofile.Templates.Agentsis the whole of turbo-go's contribution to the feature — the example agent isdocker 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 indocs/*/explanation/agent-windows.md. - The starter agents file teaches the window's keyboard as well as the format.
Enter,Alt-Enter,Tab,EscandCtrl-Ware 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.goandinternal/golangremain. 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 activereplace. Onmainthat is v0.1.0 and builds from the module proxy. Onfeature/more-syntaxestherequirenames v0.2.0, which is not published yet: that branch does not build until turbo-core is tagged and released, andgo.sumhas 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
F8shell windows all implemented. - Terminal windows (ticket 0007) implemented on 2026-08-31, and merged into
mainby the user as PR #1. Newinternal/terminalpackage — pseudo-terminal, VT/ANSI emulator, view widget — wired intoappasWindow ▸ New terminal/F8. - Project settings (ticket 0002) implemented on 2026-08-31 and merged into
mainas PR #2. Newinternal/settingspackage; TOML colouring ininternal/syntax; autosave ininternal/app;Options ▸ Create project settingsandOptions ▸ 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
mainas PR #4. Newinternal/filetreepackage;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
mainas PR #5. - Snippets (ticket 0006) implemented on 2026-08-31 and merged into
mainas PR #6. - The Go menu (ticket 0017) implemented on 2026-08-31 and merged into
mainas PR #7, fromfeature/go-format-lint. Newinternal/toolsandinternal/projectfile;terminal.Options.Argsandterminal.ViewOptions;Buffer.Reload; aGomenu onAlt-G; three output destinations; amenukey putting tools into menus of their own; theScreen.Resizefix. The user closed ticket 0017 on 2026-08-31 without thego mod init + touch main.goidea 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.tomlpickingturbo-dark(seen asESC]12;#ffd787on the wire),-theme turbo-classicoverriding 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/lspdrives the protocol directly, andinternal/appreplays 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-packageREADME.mdand the drawio diagram. The diagram was checked againstgo listprogrammatically and matches edge for edge. - Moved from Codeberg to Rickub on 2026-09-19. Module path
rickub.com/turbo-editors/turbo-go, depends onrickub.com/turbo-editors/turbo-core v1.0.0(the first turbo-core version published under that path;v0.9.0on the proxy still declares the Codeberg path and cannot be required asrickub.com/…). Every import, the Makefile'sVERSION_PKG,scripts/install.sh, the README and the docs sayrickub.com;go.sumverified against the proxy;GOWORK=off make checkgreen. The repository on this side is a freshgit initwithoriginatssh://git@rickub.com/turbo-editors/turbo-go.gitand no commit yet;01-release.tag.shmakes the first one. - Releases are cut by one script and one workflow (since 2026-09-19).
01-release.tag.shrunsmake check, refuses a tag taken locally or on origin, refuses areplaceingo.mod, commits, pushes the branch, then tags and pushes the tag. That push starts.github/workflows/release.yml, which runs the suite, builds with02-build-releases.shand publishes the release page with the binaries using the job's ownGITHUB_TOKEN.02-release.publish.shand04-release.upload-binaries.share gone — no personal token, noturbo-go.token.env.release.env(untracked) holds onlyTAGandABOUT.01had noset -eonce, so agit tagrefusing an existing tag was skipped in silence and the followinggit pushpushed the old tag — a release cut from a commit nobody meant; that guard is still there, andrelease_test.gonow runs01for real against a throwaway clone. scripts/install.shbuilds 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 ininstall_test.godrive 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/configurationduring start-up and waits for the reply; a client that ignores server-to-client requests hangs with no error.Client.handleRequestanswers 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.noCompletionsReasonandApp.languageReportturn 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;boxWidthand a test hold the two in step.- A
ListBoxcallback that nobody wires is invisible.OnSelectexisted, worked, and was covered by its own test ininternal/ui— andFileDialognever 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.Indexon 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.drawNumberruns afterdrawTitleBar, 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 readsmain.g7. Assert on the cell beside the furniture, not on the furniture.- A pseudo-terminal echoes the command line. A test that types
echo redand waits forredpasses 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.newOfflineViewbuilds aViewwith no session behind it —Drawneeds 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.fileuses pointer fields for exactly this:autosave = falsewritten out on purpose and noautosaveline must not be the same statement, or a future default flip would silently change existing projects. emitdropping empty spans makes "patch the span afterwards" unsafe. In the TOML scanner, fixing upspans[len-1].Startafter an emit that produced nothing rewrote the previous span instead. It bit a second time in the JavaScript scanner, differently:finishTemplatecalled a helper that ran the position to the end of the line, then askedtakeRestto 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:
waitForLoopTurncalleda.reloadAfterTools()itself, so the test passed with that step removed from the event loop — fixed by extractingApp.tickand having tests take a whole loop turn. AndTestStopEndsWhatTheCommandStartedTookilled 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.NewViewstarted the reading goroutine and callers then setOnChange/OnExit— a data race that existed from the terminal feature onward and that-racenever 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 isViewOptions: 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.
handleClosedKeyreturns on the first match.Alt-Sopened Search rather than Snippets and every test passed; it was found by driving the real binary.TestNoTwoMenusShareAHotKeynow covers it. - The theme completeness test only really constrains
turbo-classic.turbo-darkandborland-lightbothinherits = "turbo-classic", and inheritance is resolved at parse time into the theme's own map, soDefinesis true for an inherited key. Deleting a key from a child theme passes; deleting it fromturbo-classicfails. Check against the base theme when verifying that test. - Colours that clash are only visible on a screen.
syntax.linkwas set to lime inturbo-classic— the exact colour ofsyntax.string, making a Markdown link indistinguishable from an inlinecodespan. 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.KeyCtrlCis 67, not 3. tcell reports control bytes asKeyCtrlSpace + b, andKeyCtrlSpaceis 64. Code that testskey < 0x20to spot a control key silently matches nothing..qlty/qlty.tomlexcludeskits/**with a comment saying exactly what that hides. It hides two real defects in the kit's ownquality_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 agentv1.139.0 and a real llama.cpp (JetBrains Mellum2) by running the binary in a pty: theAlt-Amenu, 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,Tabbetween 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 qandESC]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 runever 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.goin particular compiles and passesgo vetbut has never been run — itsTIOCPTYGRANT/TIOCPTYUNLK/TIOCPTYGNAMEpath is unverified. Terminal windows are not implemented at all on Windows:pty_other.goreturnsErrUnsupportedandF8says 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.Diagnosticskeeps 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,
lsreally lists — but nobody has yet opened one insideturbo-goon a physical terminal and runvimorhtopin 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 —
F9lists the project with.githidden,→nests two levels,Enteropens 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 emptybody. Twelve are closed (0002,0003,0004,0006,0007,0009,0013,0014,0017,0019,0020,0021); eight remain open:0005wasm plugins,0008a mini agent view,0010a version number in About (implemented),0011a website,0012more themes (implemented),0015Windows support for terminal windows,0016no shadow on tiled windows,0018a 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 anepic:field on some tickets —.tickets/epics.yamllists 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.Menuhas no nested submenus.MenuItemhas noItemsfield, so the project-settings entries are two flat items under Options rather than the submenu originally asked for. Adding nesting is auichange 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 |
|