turbo-editors/turbo-corepublic Fork 0
v0.9.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

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

🛟 Updated. 28d5985 · on v0.9.0 · k33g · 13h ago
summary.md · 175 lines · 41.7 KBmarkdown
Blame HistoryOpen raw

turbo-core — project summary

A snapshot of the present. No history here — that is history.md.

What this is

The library every Turbo editor is built on: a Turbo C-style terminal IDE with a hole where the language goes. Menu bar, movable overlapping windows, modal dialogs, mouse, eight loadable TOML themes, LSP completion, terminal windows running a real shell, per-project settings, a project tree, snippets, a menu of the commands a project runs on itself, and windows onto coding agents that speak the Agent Client Protocol. None of it knows which language is being edited.

An editor built on it is a command, a profile.Profile, and a scanner.

Module path codeberg.org/turbo-editors/turbo-core. Go 1.26.5. Remote: ssh://git@codeberg.org/turbo-editors/turbo-core.git.

Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted. Nearly all of it moved unchanged.

Architecture

Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.

app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
       editor   → {buffer, syntax, ui, theme}
       terminal → {ui, theme, tcell, golang.org/x/sys}
       filetree → {ui, theme, tcell}
       acp      → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml}
       settings │ snippets │ tools → {toml, projectfile, profile}
       syntax   → theme
       ui       → theme
       lsp      → {jsonrpc, profile}
       jsonrpc  → stdlib only
       theme    → {tcell, toml}   ← takes a directory, not a profile
Package What it holds Depends on
profile The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug stdlib only
buffer Text of one file: lines of runes, cursor, selection, undo, search, load/save stdlib only
projectfile Atomic writes of a project's own TOML files stdlib only
version What build of itself the binary is: linker stamp, then Go build info stdlib only
lsp LSP framing, JSON-RPC 2.0, the child process profile
theme TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance tcell, toml
syntax Eight built-in languages, the registry an editor adds its own to, the scanner toolkit theme
settings snippets tools A project's own three files toml, projectfile, profile
ui Turbo Vision widgets theme, tcell
editor The editing widget buffer, syntax, theme, ui
terminal Pty, VT/ANSI emulator, the widget theme, ui, tcell, x/sys
filetree The project as an expandable tree theme, ui, tcell
app Assembly: menus, dialogs, event routing, completion, the server's lifecycle all of the above

The organising rule: the packages holding the interesting logic do not know a terminal exists. buffer, lsp, syntax and profile are tested by calling functions and comparing values; ui, editor, filetree and app through tcell's SimulationScreen; terminal splits the same way internally.

docs/diagrams/packages.drawio is generated from go list and verified against it edge for edge.

Decisions in force

  • profile.Profile is a struct, not an interface. An interface would be implemented once per editor — a type and a set of methods returning constants. A struct is filled in once per editor: a literal. Filling in a literal is something you do by reading the reference; implementing an interface is something you do by reading somebody else's implementation. It also puts the whole difference between two editors in one readable place.
  • The language an editor is for is registered by that editor, not shipped here. Eight languages are built in — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor.
  • syntax.Register writes into package-level state, the shape the standard library gives the same problem in image.RegisterFormat. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry through editor.View, syntax.Cache and every call site. It is not safe from two goroutines, and there is no reason for it to be: registration belongs in main, beside the flags.
  • The Class set is closed. A language registered from outside colours itself with the seventeen values and no others, which is what lets one theme colour every language an editor will ever learn.
  • Ctrl-Y deletes a line; redo moved to Ctrl-R. Turbo C's key for delete-line and this editor's key for redo were the same one, and between an editor that looks like Turbo C and a habit picked up here, the first has the better claim. Ctrl-Shift-Z was not available to move redo to: a terminal delivers it as plain Ctrl-Z. This is the one user-visible key change the project has made.
  • Ctrl-N opens a blank line above the cursor, leaving the cursor on its own text one line lower — Turbo C again. The new line is blank rather than indented like its neighbour: an indent nobody asked for becomes trailing whitespace the moment they change their mind.
  • A double click selects the word; anything else selects nothing. The word is the one IsWordRune defines, so word movement, completion and double-click all agree. A click on a space or a bracket moves the cursor and selects nothing, because editors disagree about what a run of punctuation means and nothing is at least predictable. Clicks are counted only when no drag is in progress — tcell sends Button1 for every event of a drag, and counting those turns a slow drag into a double click.
  • editor.View has an injectable clock, as app.App does, and for the same reason: a double click measured against the real clock is a test that passes or fails by how fast the machine is.
  • The editor asks the language server nine questions, not one. completion and hover; definition, typeDefinition, implementation and references, which share one answer shape and therefore one decoder; documentSymbol and workspace/symbol. Plus publishDiagnostics, which arrives unasked. All of it is in the library, so every editor gets it from naming a server.
  • A single answer is the exception. GoToDefinition took locations[0] and threw the rest away, so an interface with four implementations sent you to one of them, chosen by the server's ordering. One answer now jumps, several offer a list showing each file, line and the text of that line — read from an open window when there is one, because a file edited and not saved would otherwise be listed with text it no longer has.
  • "Nothing found" and "the server is not ready" are told apart, everywhere. They are the same empty answer and very different news, and conflating them is the most confusing way completion has ever failed here.
  • Symbols have three shapes in the protocol and one here. documentSymbol answers nested or flat, workspace/symbol may answer a location with no range. The two document shapes are told apart by selectionRange, not by children — children are optional, and a file whose symbols have none would be read as flat and lose every position. A symbol is located by its name's range, not its declaration's, so jumping lands on the function rather than on the comment above it.
  • editor does not import lsp, and will not. A gutter mark is an editor.Severity; whoever holds the diagnostics translates on the way in. Importing the protocol into the package that draws text, to draw one character, would put the protocol underneath the drawing.
  • A mark goes in the gutter's separator column. The gutter is already digits + 1 wide, so a mark costs no layout — not the text's left edge, not the cursor's screen column, not where a click lands. The accepted consequence: hiding the line numbers hides the marks, because the alternative makes the text jump sideways the first time a server says anything.
  • Diagnostics are keyed by absolute path. A server publishes absolute URIs; a buffer opened as turbo-go main.go holds the relative path. Keyed by whatever arrived, the two never met — and the failure was invisible, because an editor with no error to show and one that cannot find the error are the same blank gutter. The status bar had been failing this way for a long time. Since 2026-09-18 the map of open documents is keyed the same way (pathKey, formerly diagnosticKey), so Knows does not depend on how a path is spelt.
  • Saving announces what the server does not know. A window that starts Untitled is skipped by every didOpen (no path to announce), and announceOpenDocuments runs once — so when Save As finally names it, sending only didSave left that window without LSP until a restart, which is how a user found it ("first launch: no LSP; save, quit, relaunch: works"). announceSaved, in afterSave so both save paths get it, sends didOpen for a path the server does not Knows and didSave otherwise; and save remembers the buffer's path before SaveAs rewrites it, closing the old document on a genuine rename (renamed, compared through pathKey, so a change of spelling is not one). Rejected: announcing from Buffer.SaveAs itself, which would put the language server underneath the text type.
  • Saving the project's settings file re-applies it. UseSettings was called once, from main, so editing settings.toml in the editor did nothing until a restart. reapplySettings runs after every successful write and matches on the path, not on a.settingsPath — a project that had no settings file has nothing remembered, and creating one has to count. Only autosave is re-applied; the theme is not, because Options ▸ Theme is the live path for it and a -theme flag is the more explicit statement for its session. A file that no longer parses says Saved, but not applied: and the old values stay — a third outcome, and the only one that leaves the editor behaving unlike the file on screen.
  • Both save paths share a tail. afterSave is what the File menu's save and automatic saving have in common. It exists because the settings re-read was added for one of them and would have been missing from the other, silently.
  • Exactly one of each create/open pair is available. You can create the project file you have not got, and open the one you have; the menu greys the other out. The three files answer alike through one projectFile value each, so the behaviour is written once. Choosing create twice used to open the file instead — defensible, and not what an item saying "create" leads anyone to expect. Both branches stay reachable from the API, because a caller that is not a menu has no greying-out.
  • Channel distance is the wrong measure for prose. The readability rule compares the strongest channel, which is right for a cursor and wrong for something read word by word: Turbo Classic's comments were 128 apart and 4.05:1 to read. syntax.comment is now held to 4.5:1 WCAG in the six themes this project authors. It covers comments and nothing else — punctuation is recognised by shape, not read — and the two Catppuccin themes are exempt because their colours are a published palette copied faithfully. A second test fails if that exemption ever names a theme not shipped, so it cannot outlive its reason.
  • A compose file is not a language. compose.yaml, a Kubernetes manifest and a CI workflow are all YAML, coloured by one scanner. Rejected: a compose dialect that colours services: differently, which means carrying somebody else's schema here and watching it go stale the day they add a key.
  • XML has its own scanner rather than borrowing HTML's, for CDATA. The whole point of <![CDATA[ … ]]> is that its contents are not markup, and HTML's scanner colours the tags inside one as tags — backwards, in exactly the files most likely to contain one. It carries which construct is open, not a flag, so a --> inside a CDATA section does not end it.
  • A YAML colon is a separator only when a space or the end of the line follows it. image: nginx:1.27 is a key and one value; url: http://x is a key and one URL. This was found by a defect, not by reading the spec: the first version put every image tag and every URL in three colours. The Dockerfile scanner keeps : in a word for the same reason.
  • A YAML block scalar's extent is indentation, and a blank line inside one stays inside it. There is no closing delimiter to carry, so the carry holds the indentation the block started at. Ending at the first paragraph break would cut a shell script in a CI file in half.
  • Definition.Filenames matches the stem as well as the whole name. A Dockerfile has no extension and no shebang, and listing "Dockerfile" also recognises Dockerfile.dev without naming every variant a project invents. An all-extension name like .gitignore has an empty stem and matches nothing — otherwise the stem rule becomes a wildcard.
  • The scanner toolkit is exportedLineScanner and its eleven methods, ScanLines, TakeQuoted, OpenBlockComment, FinishBlockComment, LineIndex, the rune predicates. That is the price of the seam being real: writing a scanner outside this package has to be possible, so those names cannot be reshaped freely.
  • LineIndex exists for a scanner built on a tokeniser. Go's goes through go/scanner and works in byte offsets; every other scanner works a line at a time. The library supports both shapes because of that one scanner.
  • theme takes a userDir string; settings, snippets, tools and lsp take a profile.Profile. This looks inconsistent and is deliberate: theme needs exactly one thing from the profile, and a package that took the editor's whole identity to answer a question about a directory would be the wrong shape.
  • tools.Load fills in the default menu, so a Tool that came out of it always has a Menu. tools.DefaultMenu used to be the constant "Go" — correct for a year, wrong the moment there were two editors. The fix was not a second constant; it was noticing that the default menu is a statement about an editor.
  • app.ProjectRoot walks up looking for the profile's RootMarkers. That walk is not about Go — every language has a project root — so it belongs here with the marker in the profile. It is deliberately not the rule used for "the project" elsewhere: settings and the tree are rooted in os.Getwd() with no walk, because a module has a real boundary and "the project" does not.
  • app.Tick, app.Handle, app.Render and app.ActiveView are exported so an editor built on this library can drive itself end to end from a test, with no terminal and no event loop. That is how turbo-go and turbo-rust each test their own assembly against a real language server.
  • Every string a user reads comes from the profile, not from this package's prose. The About box did not: it said "A Turbo C-style editor for Go" in every editor built on the library, because the sentence survived the extraction as a literal. aboutText takes profile.Language now. The general form of the rule is worth more than the fix — a hardcoded language in the library is invisible until a second editor draws it.
  • lsp.NewClient takes the editor's name for the handshake. A server logs it, and "turbo-go" in the logs of a session that was actually Turbo Rust is the kind of small lie that costs somebody an afternoon.
  • Eleven themes ship, and each states its whole palette. turbo-classic, turbo-dark, borland-light, cappuccino, catppuccin-frappe, catppuccin-latte, cobalt, darcula, intellij-light, monochrome-dark, monochrome-light. Defines is satisfied by inheritance, so a theme omitting a key silently shows a colour Turbo Classic chose for its navy background; TestEveryEmbeddedThemeSetsEveryKeyItself closes that for shipped themes only. A user theme may still inherit.
  • A theme's name is not ours to change, so a renamed one keeps answering to its old name. monochrome became monochrome-dark when monochrome-light joined it, and retiredNames in load.go maps the old name onto the new one. It is resolved after the user's directory, so somebody's own monochrome.toml still wins; it is not listed by Available, so the theme dialog offers each theme once under the name it has now. What it costs is that a retired name can never be reused for a different theme — which is the same promise the rename was made to keep.
  • The two monochromes are one theme in two polarities, and not a mirror. sRGB luminance is not symmetric about the middle grey, so inverting the dark theme's values gives a light theme that reads far worse than the original: #808080, its comment, is 4.74:1 on #121212 and 3.40:1 on the #eeeeee page. The light theme keeps the dark one's ordering class for class and compresses the reading scale into #626262#000000, which is what a light ground affords.
  • The two JetBrains themes are "after" rather than faithful, and the departures are counted. darcula and intellij-light take their colours from JetBrains' own published files — DefaultColorSchemesManager.xml, themes/Light.xml, themes/darcula.theme.json — fetched rather than remembered. Three values had to move, all for readability rules this project already had: Darcula's comment (#808080, 3.59:1) and its caret row (#323232, seven from the page against a required sixteen), and IntelliJ Light's comment (#8c8c8c, 3.36:1). Each lift is the smallest that clears the rule, and the caret row borrows JetBrains' own window background rather than inventing a grey. They are therefore not in palettesWeDoNotOwn: that list is for palettes copied exactly, and these are not.
  • The Catppuccin themes use the published palettes unchanged. A theme called Catppuccin that is not those exact values is a different theme with a borrowed name. Latte needed a different mapping from Frappé, not different numbers: its accents are far more saturated because they carry against paper.
  • Five rules hold a theme to being readable, four of them arithmetic in editor: the cursor is ≥64 from its line and never a plain reversal of it, the current line ≥16 from the page, and text meant to be read ≥64 from its background — exempting the furniture, which sits between 20 and 70 in every theme by design. The fifth is the one no measurement finds: two syntax classes a reader meets side by side must not be drawn identically.
  • A tool's command can ask for values, in {{double braces}}. Single braces were the obvious spelling and are wrong: awk '{print $1}' and find . -exec rm {} + are ordinary things to put in a tools file, and the first would become a box asking for "print $1". The value is shell-quoted by default, because a path with a space silently becoming two arguments is a bug nobody can see; a trailing ... inside the braces asks for it verbatim, which is the case where one field stands for several arguments. tools parses and fills; app draws the box. Values are remembered per tool, for the session only — the project's own directory holds what the project decided, not a filter somebody typed while chasing one test.
  • A half-typed placeholder is refused when the file is read, not when the tool is chosen: an unclosed {{ reaching the shell fails in a way that names neither the tool nor the file. Same rule an unknown output already follows.
  • A parameters box that will not fit is refused with a message. app.MaxParameterFields is computed from the screen's height, because a box whose OK button is below the bottom of the terminal can only be answered with Escape, which cancels.
  • Terminal windows on Windows go through a pseudo-console (ConPTY), behind the same Session. terminal.Session holds a small child interface: session_unix.go is the /dev/ptmx + exec.Cmd path, pty_windows.go creates two pipes and a pseudo-console and starts the shell with CreateProcess by hand — Go's exec cannot carry the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE attribute — and pty_other.go still returns ErrUnsupported for the BSDs. What conhost writes to the output pipe is the same VT a Unix shell writes to a pty, so the emulator needed nothing. Three Windows facts shaped the file: UpdateProcThreadAttribute is called through a LazyProc because the attribute's value is the handle itself and x/sys's wrapper wants an unsafe.Pointer (the conversion go vet flags); conhost keeps the output pipe open until the console is closed, so a goroutine waits for the shell and closes the console — that is what turns a shell exiting into the io.EOF the view relies on; and the shell is %COMSPEC% (cmd.exe), never $SHELL. Built, vetted (GOOS=windows go vet) and unit-tested in its pure parts (windows.go: environment block, command line); never run against a real conhost — nobody on this project has a Windows machine.
  • The tools shell is per platform, and cmd.exe gets its command line verbatim. tools.Shell() is /bin/sh on Unix and %COMSPEC% on Windows; tools.ShellArgs(command) is ["-c", …] or ["/S", "/C", …]. cmd.exe does not read a command line by the C runtime's rules — Go's composition would turn a " inside the command into \" — so on Windows the line is written as "cmd.exe" /S /C "<command>" and handed to exec through SysProcAttr.CmdLine, and terminal.windowsCommandLine does the same for a terminal-output tool. tools.Shell was a constant and is now a function: a minor version bump.
  • Stopping a command stops its tree on both platforms. Unix: a process group (Setpgid) killed by negative pid. Windows: a job object created with KILL_ON_JOB_CLOSE, assigned after the process starts (a child spawned in those first milliseconds escapes — exec.Cmd cannot start a process suspended), terminated by TerminateJobObject. Both behind the group interface in tools/run.go.
  • Two direct dependencies plus one: tcell/v2, BurntSushi/toml, and golang.org/x/sys for the pty ioctls and, on Windows, the pseudo-console, process and job-object calls. The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written. Do not add another without saying why.

The decisions inherited from turbo-go about the editor's behaviour — the event loop being state-driven, upward communication by function field, dialogs being asynchronous, undo merging runs, ReplaceRange being the single mutation path, the terminal emulator's deliberate boundaries — are unchanged and are recorded in turbo-go's own .memory/history.md, where they were made.

  • jsonrpc is lsp's protocol layer, extracted when a second protocol arrived. The Language Server Protocol and the Agent Client Protocol differ at that level in exactly one thing: how a message is marked off from the next — a Content-Length header for one, a newline for the other. That is the Framer interface; everything above it is shared. Rejected: a near-copy of Conn inside acp, which is 250 duplicated lines and two places for one bug.
  • A JSON-RPC request may be answered later. RequestFunc takes a *jsonrpc.Request and returns nothing. Every question a language server asks can be answered where it arrives; session/request_permission cannot be, because the answer comes from a dialog somebody has to look at, and opening one belongs to the goroutine that draws. Reply is guarded by a sync.Once — a window closing tears down a dialog that may already have been answered, and two responses carrying one id desynchronise a peer that matches answers to requests by exactly that id.
  • The two id directions are separate spaces, and that is not theoretical. docker agent really does send id: 1 while a call of ours carrying the same id is in flight. A connection sharing one map would hand the peer's request to the caller waiting for an answer. TestAPeersRequestIdDoesNotStealAnAnswerMeantForOurOwnCall is what holds it.
  • An agent is a window, not a panel — the project tree's argument, unchanged: a docked panel means the desktop growing reserved edges, and fitInto, the grow modes, maximising, tiling and cascading all having to respect them. It also makes "several agents at once" fall out rather than be designed.
  • One process and one session per window, started when the window opens. Closing it is an unambiguous end and asks nothing — a conversation is a running process, not unsaved work, which is the bargain terminals already make. Rejected: one long-lived agent multiplexed across windows, which means deciding which window a session/update belongs to and what to do with a window whose session died while the process lived.
  • The permission dialog is opened from the event loop, never from the message. Fourth instance of the same rule (autosave, the re-announcement, terminal redraws are the others): PostEvent may drop what does not fit, so an event may cause a turn but must never be the only thing carrying a fact. Permission.Answer and Cancel also write on a goroutine of their own, because writing blocks until the agent reads and an agent that has stopped reading must not freeze the editor on the keystroke that answers it. The same applies to Session.Cancel. This was found by a test hanging, not by reasoning.
  • Escape on a permission is the agent's own reject option, not a cancellation. The turn is still running and the agent may carry on without what it asked for. Inventing an option id the agent does not know leaves it as stuck as saying nothing, so RejectOption prefers its reject_once and falls back to the last option it offered.
  • The agent reads the buffer, not the disk. A file open with unsaved changes is answered from its buffer: the alternative is an agent reviewing the version you have just moved past, which is wrong precisely when you are most likely to be asking. Writes go into the buffer too, marked modified — an agent quietly rewriting a file under an open window would be the worst possible version of this. The cost, accepted: the agent sees text no other tool can see, which is already true of completion.
  • The conversation is a value the window merely draws. acp.Transcript coalesces token-level chunks into entries, folds each tool_call_update onto the tool_call its id matches, and hands the window blocks that are either prose or code-in-a-named-language. It knows nothing about a terminal, which is what makes the drawing tests deterministic — a window drawn from a fixed transcript cannot race a live agent.
  • Only a fence decides that something is code. No scanner is guessed at from the shape of the text; a fence naming a language nothing colours is drawn plainly. The tag is matched against syntax.Registered() rather than a list kept in acp, so an editor registering Rust colours a ```rust block with no change to the library. A handful of aliases (sh, golang, js, yml, md) are mapped, because a model writes them by habit.
  • Prose is wrapped; code is not. A wrapped line of code would need its spans remapped onto the pieces, and half a line of Go under the line above reads worse than one that is simply too long. Editors clip code; they do not reflow it.
  • The agent window adds no theme keys. A speaker's label is the keyword style, a thought the comment style, a tool call the type style, a failure and the editor's own notices diagnostic.error, and code whatever its scanner says. The project tree needed keys of its own because it would otherwise have borrowed a colour chosen against a dialog background; nothing like that is true here, since an agent window's body is window.body, which is what the syntax styles are already chosen and contrast-tested against. Eleven themes therefore colour agent windows correctly untouched. Given up: a theme cannot make thoughts quiet without making comments quiet. agent.* keys can be added later if that turns out to matter.
  • acp.toml is read from the user's directory and then the project's, and the project wins by name — the snippets rule, not the tools one. An agent is a program you have installed and configured, which is a fact about you rather than about one repository.
  • A key the format does not define is refused, and the whole file with it. A misspelt comand silently ignored leaves an agent that cannot start and a file that looks right; a half-loaded menu offering three of your five agents is worse than an error naming the line.
  • What the agent calls itself is kept but not shown. docker agent reports "docker agent" — the runtime, not the assistant — while the menu entry is the name the user chose. A window titled "Bob (llama.cpp)" whose messages are signed "docker agent" is two names for one thing. AgentInfo() keeps it for the status dialog.
  • NewSession takes a stream, not a command, exactly as lsp.NewClient does, which is what lets the whole client be driven from a test against an agent in the same process over net.Pipe. Start is the thin layer that builds that stream out of a child process's pipes.
  • An unknown session/update is ignored and counted, never refused. The protocol grows, and an editor that stopped talking to an agent because it learnt a new message would be wrong more often than it was right. The count is in Agent ▸ Agent status, so "the protocol moved on" is visible rather than silent.
  • The spinner is a function of the clock, not a counter. acp.Spinner(now) — so nothing has to be reset when a turn begins, two windows thinking at once turn in step, and a test asserts on a frame without waiting for one. Drawing from the clock means something else must cause the redraw, so a session running a turn wakes the loop at the spinner's rate; that is allowed to be a ticker because a dropped tick cannot strand anything. The window title deliberately does not animate: it is also what the window list and the Alt-digit menu show.
  • A command is a text prompt, not a method, and the picker is a convenience over that fact. available_commands_update lists what the agent answers to; the client sends /web agent client protocol as one text block, exactly as Zed does. So the list / opens in the box is recomputed from Session.Commands() and the word under the cursor on every key and every frame — nothing stored, nothing to keep in step — and / anywhere but the first character of a single-line box is a character. Rejected: a menu built from the list, which would sit far from the box, need rebuilding on every update, and still end by typing /web into it.
  • Enter on the picker completes an unfinished word and sends a finished one. A word that already reads exactly as a command has nothing left to complete; making Enter complete it anyway costs a keystroke per command for nothing. Tab always completes. Esc closes the list until the text changes — otherwise it would reopen on the next frame.
  • A mention replaces its name in the text with the file. @app/menus.go becomes a resource block (uri, mimeType, full text, read through the same path as fs/read_text_file) when the agent declared promptCapabilities.embeddedContext, a resource_link otherwise or when the file cannot be read — never nothing. Text blocks go either side. The transcript keeps the line as typed. The name has to end the word (@main.go does not name main.gopher) and has to be in the list the editor supplied, so an e-mail address stays text. View.Files func() []Mention is the seam: nil means @ is only a character; the app supplies a walk of the project (.git skipped, 5 000 files max) done afresh each time the list opens and cached while it is open.
  • Media types are listed, not looked up. mimeOf has its own table for the family's languages and falls back to mime.TypeByExtension; the system table differs between Linux and macOS for .go, and an agent keying on the type deserves one answer from every copy of the editor.
  • Copying goes to two clipboards. The editor's own (Shift-Ins pastes it into a file here) and the system's, through the terminal's OSC 52 (Ctrl-V pastes it anywhere else). Nothing verifies the second: the sequence has no reply, a terminal may refuse it for security, and a message promising something that did not happen is worse than one that stays quiet. The status bar says how many lines were copied, which is true either way.
  • Selection in a conversation is by whole lines, and with none, Ctrl-C copies the region under the cursor. Nothing in a conversation is edited, so half a line is never what somebody means, and whole lines keep a code block's indentation. A Line carries a Region — one fenced block, one passage of prose, one tool's output — and a speaker's label and a tool call's heading are regions of their own, which is what keeps ‣ Bob (llama.cpp) out of a block pasted into a source file. That last part was found, not designed: the first version copied the label, caught by copying from the real binary and reading the OSC 52 payload back off the wire.

Build, test, run

make test        # the whole suite — the single documented command
make version     # the version this checkout would publish
make race        # the same under the race detector
make cover       # statement coverage per package
make check       # fmt, vet and test
make help        # every target

Quality gate, separate from the tests:

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

State as of 2026-09-01

  • Tool parameters implemented. A {{label}} in a tools command opens a box before the command runs. tools/placeholder.go parses and fills; app's ParametersDialog asks. Verified in a real pty against both editors.
  • v0.1.0 is released. The tag is on main at f1f0375, the release page is at https://codeberg.org/turbo-editors/turbo-core/releases/tag/v0.1.0, and go get codeberg.org/turbo-editors/turbo-core@v0.1.0 works from the module proxy. Both editors require it with no active replace, verified by running their suites against the real published module.
  • HEAD is one commit past the tag. bc4a464 holds 02-release.publish.sh, its eight tests, and the release how-to's rewrite — so none of that is in v0.1.0. It changes no API, so nothing depending on the library is affected; the only visible consequence is that the v0.1.0 release page's documentation links, which are pinned to the tag, show the how-to as it was before the publish script existed. Fold it into the next tag rather than moving v0.1.0.
  • Two release scripts. 01-release.tag.sh runs make check, refuses a tag taken locally or on origin, refuses a go.mod with a replace, and tags only after pushing. 02-release.publish.sh creates the Codeberg release page for a tag that is already there, building its JSON with jq, telling the HTTP statuses apart, and taking --dry-run. There is deliberately no 03 or 04: those build and attach binaries, and a library has none. release.env and turbo-core.token.env are gitignored by *.env.
  • Extracted and green. Sixteen packages, whole suite passing, and passing under -race.
  • Eight languages are coloured. YAML, XML and Dockerfiles were added on 2026-09-01, and Definition grew a Filenames field so a file with no extension and no shebang — a Dockerfile — can be recognised. LanguageOf now looks at the extension, then the name, then the first line.
  • Quality gate: PASS. 0 errors, 0 warnings, 0 smells, total complexity 2306 (run 29, 2026-09-17).
  • Windows terminal windows and tools (2026-09-17): terminal/pty_windows.go, terminal/windows.go, tools/shell_{unix,windows}.go, app rewired; cross-compiled for windows/amd64 and windows/arm64 and darwin, -race green on terminal and tools. Uncommitted, on main, unreleased — needs a tag (v0.9.0: tools.Shell const → func) and a re-pin in every editor before any of them gains it; their docs already describe it.
  • Six editors are built on it — turbo-go, turbo-rust, turbo-python, turbo-moonbit, turbo-golo and turbo-js — all in repositories beside this one, depending on it from the module proxy with no active replace. turbo-js was added on 2026-09-16 and pins v0.8.0; like turbo-python, turbo-moonbit and turbo-golo before it, it needed no change to the library, which is the strongest evidence the seam holds that this project has. It is the first editor to replace a built-in scanner: it registers its own JavaScript under syntax.LanguageJavaScript, which Register allows by design, and adds JSON beside it.
  • Documentation: 12 pages × EN + FR under docs/, a README.md per package, and a drawio diagram generated from go list and verified against it.
  • The tutorial has been run start to finish, verbatim, in a throwaway module: it builds, the menu bar comes up with the new editor's own menu on it, and the colours land. Both its intermediate states compile.

Not yet established

  • Agent windows have been driven, but only from a script. The whole path was exercised against a real docker agent v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; nobody has typed into one with their hands. Untried: the mouse, Tab between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.

  • The / and @ pickers have only been driven by tests. Commands, hints, embedded and linked mentions are all asserted on the wire against the fake agent, and the popup on a simulation screen, but no real agent has announced a command to this client yet — docker agent was not available in the sandbox on 2026-09-15 — and the user's own mini-me agent (mm -acp, which Zed discovers commands from) has never been pointed at an agent window. Whether its available_commands_update arrives before or after session/new returns, and whether it embeds context, are both unknown.

  • release_test.go fails in this sandbox for a reason that is not the code. It copies the module with cp -r, and the sandbox's filesystem returns NUL bytes from cp for some recently written inodes (acp/picker.go, app/agent_files.go on 2026-09-15). Recreating the files at fresh inodes did not clear it this time. cat, git, gofmt, go build and go test ./acp ./app all read the right bytes.

  • fs/read_text_file and fs/write_text_file have never been exercised by a real agent. They are covered by tests against the fake one, and docker agent did not call them: its filesystem toolset is its own, server-side, so it never asks the client. An agent that does use the client capability is the untested case.

  • Long conversations are unmeasured. The window re-wraps the whole transcript every frame. It was fine at a few dozen entries; nothing has been profiled, and MaxEntryBytes (a megabyte) is a guess rather than a measurement.

  • session/load, authenticate and the terminal capability are not implemented, deliberately, and each for a reason written down in docs/*/explanation/agent-windows.md in turbo-go.

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

  • Windows and macOS are untested. terminal/pty_darwin.go compiles and passes vet but has never been run. The Windows pseudo-console path (added 2026-09-17) has never been started against a real conhost: it compiles for amd64 and arm64, passes GOOS=windows go vet, and its pure parts are unit-tested everywhere, but F8, a terminal-output tool, Ctrl-C, resizing and closing have not been exercised on Windows. The five-step protocol is in docs/*/how-to/use-a-terminal.md of every editor and in handoffs/2026-09-17-windows-terminal.md. Two things are most likely to be wrong first: the order of TerminateProcess / ClosePseudoConsole / pipe closes in Close, and conhost's start-up sequences (cursor-shape, ?9001h) meeting an emulator that has only ever read Unix shells.

  • The extension point has been exercised by seven editors — Go, Rust, Python, MoonBit, Golo, JavaScript, and the Zig editor the tutorial builds — but all of them were written by the same hand. Nobody outside has tried to add a language.

  • Two things the sixth editor found in the library, neither fixed here. lsp.Client.DidOpen sends languageId: "go" for every editor (lsp/client.go); tsserver decides by file extension so nothing broke, but it is a hardcoded language in a string a server reads, and the profile is where it belongs. And the client reads only publishDiagnostics: TypeScript 7's native server (tsc --lsp --stdio) offers diagnostics pull-style (textDocument/diagnostic) and publishes none, so with it every gutter stays blank — which is why turbo-js names typescript-language-server with typescript@6 instead. Learning to pull is a library cycle of its own.

  • The Save-As announcement fix (2026-09-18) has not been driven against a real gopls. It is covered by five tests against the fake server, each verified by breaking the code it covers, but nobody has yet started the real editor, typed into an Untitled window, saved it and completed. The other first-launch suspect the user's report could also fit — gopls's cold cache on its very first run — remains unmeasured.

  • No performance measurement. Behaviour on a file of tens of thousands of lines is unknown.

  • profile.Templates' formatting contract is documented, not enforced. A template with the wrong number of verbs produces %!s(MISSING) in somebody's project; each editor tests its own.

  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
# turbo-core — project summary

*A snapshot of the present. No history here — that is `history.md`.*

## What this is

The library every Turbo editor is built on: a Turbo C-style terminal IDE with a hole where the language goes. Menu bar, movable overlapping windows, modal dialogs, mouse, eight loadable TOML themes, LSP completion, terminal windows running a real shell, per-project settings, a project tree, snippets, a menu of the commands a project runs on itself, and windows onto coding agents that speak the Agent Client Protocol. None of it knows which language is being edited.

An editor built on it is a command, a `profile.Profile`, and a scanner.

Module path `codeberg.org/turbo-editors/turbo-core`. Go 1.26.5. Remote: `ssh://git@codeberg.org/turbo-editors/turbo-core.git`.

Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted. Nearly all of it moved unchanged.

## Architecture

Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.

```
app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
       editor   → {buffer, syntax, ui, theme}
       terminal → {ui, theme, tcell, golang.org/x/sys}
       filetree → {ui, theme, tcell}
       acp      → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml}
       settings │ snippets │ tools → {toml, projectfile, profile}
       syntax   → theme
       ui       → theme
       lsp      → {jsonrpc, profile}
       jsonrpc  → stdlib only
       theme    → {tcell, toml}   ← takes a directory, not a profile
```

| Package | What it holds | Depends on |
| --- | --- | --- |
| `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |
| `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |
| `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |
| `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |
| `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |
| `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` |
| `syntax` | Eight built-in languages, the registry an editor adds its own to, the scanner toolkit | `theme` |
| `settings` `snippets` `tools` | A project's own three files | `toml`, `projectfile`, `profile` |
| `ui` | Turbo Vision widgets | `theme`, `tcell` |
| `editor` | The editing widget | `buffer`, `syntax`, `theme`, `ui` |
| `terminal` | Pty, VT/ANSI emulator, the widget | `theme`, `ui`, `tcell`, `x/sys` |
| `filetree` | The project as an expandable tree | `theme`, `ui`, `tcell` |
| `app` | Assembly: menus, dialogs, event routing, completion, the server's lifecycle | all of the above |

The organising rule: **the packages holding the interesting logic do not know a terminal exists.** `buffer`, `lsp`, `syntax` and `profile` are tested by calling functions and comparing values; `ui`, `editor`, `filetree` and `app` through tcell's `SimulationScreen`; `terminal` splits the same way internally.

`docs/diagrams/packages.drawio` is generated from `go list` and verified against it edge for edge.

## Decisions in force

- **`profile.Profile` is a struct, not an interface.** An interface would be implemented once per editor — a type and a set of methods returning constants. A struct is filled in once per editor: a literal. Filling in a literal is something you do by reading the reference; implementing an interface is something you do by reading somebody else's implementation. It also puts the *whole* difference between two editors in one readable place.
- **The language an editor is *for* is registered by that editor, not shipped here.** Eight languages are built in — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor.
- **`syntax.Register` writes into package-level state**, the shape the standard library gives the same problem in `image.RegisterFormat`. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry through `editor.View`, `syntax.Cache` and every call site. It is **not** safe from two goroutines, and there is no reason for it to be: registration belongs in `main`, beside the flags.
- **The `Class` set is closed.** A language registered from outside colours itself with the seventeen values and no others, which is what lets one theme colour every language an editor will ever learn.
- **`Ctrl-Y` deletes a line; redo moved to `Ctrl-R`.** Turbo C's key for delete-line and this editor's key for redo were the same one, and between an editor that looks like Turbo C and a habit picked up here, the first has the better claim. `Ctrl-Shift-Z` was not available to move redo to: a terminal delivers it as plain `Ctrl-Z`. This is the one user-visible key change the project has made.
- **`Ctrl-N` opens a blank line *above* the cursor**, leaving the cursor on its own text one line lower — Turbo C again. The new line is blank rather than indented like its neighbour: an indent nobody asked for becomes trailing whitespace the moment they change their mind.
- **A double click selects the word; anything else selects nothing.** The word is the one `IsWordRune` defines, so word movement, completion and double-click all agree. A click on a space or a bracket moves the cursor and selects nothing, because editors disagree about what a run of punctuation means and nothing is at least predictable. Clicks are counted only when no drag is in progress — tcell sends `Button1` for every event of a drag, and counting those turns a slow drag into a double click.
- **`editor.View` has an injectable clock**, as `app.App` does, and for the same reason: a double click measured against the real clock is a test that passes or fails by how fast the machine is.
- **The editor asks the language server nine questions, not one.** `completion` and `hover`; `definition`, `typeDefinition`, `implementation` and `references`, which share one answer shape and therefore one decoder; `documentSymbol` and `workspace/symbol`. Plus `publishDiagnostics`, which arrives unasked. All of it is in the library, so every editor gets it from naming a server.
- **A single answer is the exception.** `GoToDefinition` took `locations[0]` and threw the rest away, so an interface with four implementations sent you to one of them, chosen by the server's ordering. One answer now jumps, several offer a list showing each file, line and the text of that line — read from an open window when there is one, because a file edited and not saved would otherwise be listed with text it no longer has.
- **"Nothing found" and "the server is not ready" are told apart**, everywhere. They are the same empty answer and very different news, and conflating them is the most confusing way completion has ever failed here.
- **Symbols have three shapes in the protocol and one here.** `documentSymbol` answers nested or flat, `workspace/symbol` may answer a location with no range. The two document shapes are told apart by `selectionRange`, **not** by `children` — children are optional, and a file whose symbols have none would be read as flat and lose every position. A symbol is located by its name's range, not its declaration's, so jumping lands on the function rather than on the comment above it.
- **`editor` does not import `lsp`, and will not.** A gutter mark is an `editor.Severity`; whoever holds the diagnostics translates on the way in. Importing the protocol into the package that draws text, to draw one character, would put the protocol underneath the drawing.
- **A mark goes in the gutter's separator column.** The gutter is already `digits + 1` wide, so a mark costs no layout — not the text's left edge, not the cursor's screen column, not where a click lands. The accepted consequence: hiding the line numbers hides the marks, because the alternative makes the text jump sideways the first time a server says anything.
- **Diagnostics are keyed by absolute path.** A server publishes absolute URIs; a buffer opened as `turbo-go main.go` holds the relative path. Keyed by whatever arrived, the two never met — and the failure was invisible, because an editor with no error to show and one that cannot find the error are the same blank gutter. The status bar had been failing this way for a long time. Since 2026-09-18 the map of **open documents** is keyed the same way (`pathKey`, formerly `diagnosticKey`), so `Knows` does not depend on how a path is spelt.
- **Saving announces what the server does not know.** A window that starts Untitled is skipped by every `didOpen` (no path to announce), and `announceOpenDocuments` runs once — so when Save As finally names it, sending only `didSave` left that window without LSP until a restart, which is how a user found it ("first launch: no LSP; save, quit, relaunch: works"). `announceSaved`, in `afterSave` so both save paths get it, sends `didOpen` for a path the server does not `Knows` and `didSave` otherwise; and `save` remembers the buffer's path before `SaveAs` rewrites it, closing the old document on a genuine rename (`renamed`, compared through `pathKey`, so a change of spelling is not one). Rejected: announcing from `Buffer.SaveAs` itself, which would put the language server underneath the text type.
- **Saving the project's settings file re-applies it.** `UseSettings` was called once, from `main`, so editing `settings.toml` in the editor did nothing until a restart. `reapplySettings` runs after every successful write and matches on the **path**, not on `a.settingsPath` — a project that had no settings file has nothing remembered, and creating one has to count. Only autosave is re-applied; the theme is not, because Options ▸ Theme is the live path for it and a `-theme` flag is the more explicit statement for its session. A file that no longer parses says `Saved, but not applied:` and the old values stay — a third outcome, and the only one that leaves the editor behaving unlike the file on screen.
- **Both save paths share a tail.** `afterSave` is what the File menu's save and automatic saving have in common. It exists because the settings re-read was added for one of them and would have been missing from the other, silently.
- **Exactly one of each create/open pair is available.** You can create the project file you have not got, and open the one you have; the menu greys the other out. The three files answer alike through one `projectFile` value each, so the behaviour is written once. Choosing *create* twice used to open the file instead — defensible, and not what an item saying "create" leads anyone to expect. Both branches stay reachable from the API, because a caller that is not a menu has no greying-out.
- **Channel distance is the wrong measure for prose.** The readability rule compares the strongest channel, which is right for a cursor and wrong for something read word by word: Turbo Classic's comments were 128 apart and 4.05:1 to read. `syntax.comment` is now held to **4.5:1 WCAG** in the six themes this project authors. It covers comments and nothing else — punctuation is recognised by shape, not read — and the two Catppuccin themes are exempt because their colours are a published palette copied faithfully. A second test fails if that exemption ever names a theme not shipped, so it cannot outlive its reason.
- **A compose file is not a language.** `compose.yaml`, a Kubernetes manifest and a CI workflow are all YAML, coloured by one scanner. Rejected: a compose dialect that colours `services:` differently, which means carrying somebody else's schema here and watching it go stale the day they add a key.
- **XML has its own scanner rather than borrowing HTML's**, for CDATA. The whole point of `<![CDATA[ … ]]>` is that its contents are not markup, and HTML's scanner colours the tags inside one as tags — backwards, in exactly the files most likely to contain one. It carries *which* construct is open, not a flag, so a `-->` inside a CDATA section does not end it.
- **A YAML colon is a separator only when a space or the end of the line follows it.** `image: nginx:1.27` is a key and one value; `url: http://x` is a key and one URL. This was found by a defect, not by reading the spec: the first version put every image tag and every URL in three colours. The Dockerfile scanner keeps `:` in a word for the same reason.
- **A YAML block scalar's extent is indentation, and a blank line inside one stays inside it.** There is no closing delimiter to carry, so the carry holds the indentation the block started at. Ending at the first paragraph break would cut a shell script in a CI file in half.
- **`Definition.Filenames` matches the stem as well as the whole name.** A `Dockerfile` has no extension and no shebang, and listing `"Dockerfile"` also recognises `Dockerfile.dev` without naming every variant a project invents. An all-extension name like `.gitignore` has an empty stem and matches nothing — otherwise the stem rule becomes a wildcard.
- **The scanner toolkit is exported** — `LineScanner` and its eleven methods, `ScanLines`, `TakeQuoted`, `OpenBlockComment`, `FinishBlockComment`, `LineIndex`, the rune predicates. That is the price of the seam being real: writing a scanner outside this package has to be possible, so those names cannot be reshaped freely.
- **`LineIndex` exists for a scanner built on a tokeniser.** Go's goes through `go/scanner` and works in byte offsets; every other scanner works a line at a time. The library supports both shapes because of that one scanner.
- **`theme` takes a `userDir string`; `settings`, `snippets`, `tools` and `lsp` take a `profile.Profile`.** This looks inconsistent and is deliberate: theme needs exactly one thing from the profile, and a package that took the editor's whole identity to answer a question about a directory would be the wrong shape.
- **`tools.Load` fills in the default menu**, so a `Tool` that came out of it always has a `Menu`. `tools.DefaultMenu` used to be the constant `"Go"` — correct for a year, wrong the moment there were two editors. The fix was not a second constant; it was noticing that the default menu is a statement about an editor.
- **`app.ProjectRoot` walks up looking for the profile's `RootMarkers`.** That walk is not about Go — every language has a project root — so it belongs here with the marker in the profile. It is deliberately **not** the rule used for "the project" elsewhere: settings and the tree are rooted in `os.Getwd()` with no walk, because a module has a real boundary and "the project" does not.
- **`app.Tick`, `app.Handle`, `app.Render` and `app.ActiveView` are exported** so an editor built on this library can drive itself end to end from a test, with no terminal and no event loop. That is how turbo-go and turbo-rust each test their own assembly against a real language server.
- **Every string a user reads comes from the profile, not from this package's prose.** The About box did not: it said "A Turbo C-style editor for Go" in every editor built on the library, because the sentence survived the extraction as a literal. `aboutText` takes `profile.Language` now. The general form of the rule is worth more than the fix — a hardcoded language in the library is invisible until a second editor draws it.
- **`lsp.NewClient` takes the editor's name** for the handshake. A server logs it, and "turbo-go" in the logs of a session that was actually Turbo Rust is the kind of small lie that costs somebody an afternoon.
- **Eleven themes ship, and each states its whole palette.** `turbo-classic`, `turbo-dark`, `borland-light`, `cappuccino`, `catppuccin-frappe`, `catppuccin-latte`, `cobalt`, `darcula`, `intellij-light`, `monochrome-dark`, `monochrome-light`. `Defines` is satisfied by inheritance, so a theme omitting a key silently shows a colour Turbo Classic chose for its navy background; `TestEveryEmbeddedThemeSetsEveryKeyItself` closes that for shipped themes only. A **user** theme may still inherit.
- **A theme's name is not ours to change, so a renamed one keeps answering to its old name.** `monochrome` became `monochrome-dark` when `monochrome-light` joined it, and `retiredNames` in `load.go` maps the old name onto the new one. It is resolved *after* the user's directory, so somebody's own `monochrome.toml` still wins; it is not listed by `Available`, so the theme dialog offers each theme once under the name it has now. What it costs is that a retired name can never be reused for a different theme — which is the same promise the rename was made to keep.
- **The two monochromes are one theme in two polarities, and not a mirror.** sRGB luminance is not symmetric about the middle grey, so inverting the dark theme's values gives a light theme that reads far worse than the original: `#808080`, its comment, is 4.74:1 on `#121212` and 3.40:1 on the `#eeeeee` page. The light theme keeps the dark one's *ordering* class for class and compresses the reading scale into `#626262``#000000`, which is what a light ground affords.
- **The two JetBrains themes are "after" rather than faithful, and the departures are counted.** `darcula` and `intellij-light` take their colours from JetBrains' own published files — `DefaultColorSchemesManager.xml`, `themes/Light.xml`, `themes/darcula.theme.json` — fetched rather than remembered. Three values had to move, all for readability rules this project already had: Darcula's comment (#808080, 3.59:1) and its caret row (#323232, seven from the page against a required sixteen), and IntelliJ Light's comment (#8c8c8c, 3.36:1). Each lift is the smallest that clears the rule, and the caret row borrows JetBrains' own window background rather than inventing a grey. They are therefore **not** in `palettesWeDoNotOwn`: that list is for palettes copied exactly, and these are not.
- **The Catppuccin themes use the published palettes unchanged.** A theme called Catppuccin that is not those exact values is a different theme with a borrowed name. Latte needed a different *mapping* from Frappé, not different numbers: its accents are far more saturated because they carry against paper.
- **Five rules hold a theme to being readable**, four of them arithmetic in `editor`: the cursor is ≥64 from its line and never a plain reversal of it, the current line ≥16 from the page, and text meant to be read ≥64 from its background — exempting the furniture, which sits between 20 and 70 in every theme by design. The fifth is the one no measurement finds: two syntax classes a reader meets side by side must not be drawn identically.
- **A tool's command can ask for values, in `{{double braces}}`.** Single braces were the obvious spelling and are wrong: `awk '{print $1}'` and `find . -exec rm {} +` are ordinary things to put in a tools file, and the first would become a box asking for "print $1". The value is **shell-quoted by default**, because a path with a space silently becoming two arguments is a bug nobody can see; a trailing `...` inside the braces asks for it verbatim, which is the case where one field stands for several arguments. `tools` parses and fills; `app` draws the box. Values are remembered **per tool, for the session only** — the project's own directory holds what the project decided, not a filter somebody typed while chasing one test.
- **A half-typed placeholder is refused when the file is read**, not when the tool is chosen: an unclosed `{{` reaching the shell fails in a way that names neither the tool nor the file. Same rule an unknown `output` already follows.
- **A parameters box that will not fit is refused with a message.** `app.MaxParameterFields` is computed from the screen's height, because a box whose OK button is below the bottom of the terminal can only be answered with Escape, which cancels.
- **Terminal windows on Windows go through a pseudo-console (ConPTY), behind the same `Session`.** `terminal.Session` holds a small `child` interface: `session_unix.go` is the `/dev/ptmx` + `exec.Cmd` path, `pty_windows.go` creates two pipes and a pseudo-console and starts the shell with `CreateProcess` by hand — Go's `exec` cannot carry the `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` attribute — and `pty_other.go` still returns `ErrUnsupported` for the BSDs. What conhost writes to the output pipe is the same VT a Unix shell writes to a pty, so the emulator needed nothing. Three Windows facts shaped the file: `UpdateProcThreadAttribute` is called through a `LazyProc` because the attribute's value is the handle itself and x/sys's wrapper wants an `unsafe.Pointer` (the conversion `go vet` flags); conhost keeps the output pipe open until the console is closed, so a goroutine waits for the shell and closes the console — that is what turns a shell exiting into the `io.EOF` the view relies on; and the shell is `%COMSPEC%` (cmd.exe), never `$SHELL`. **Built, vetted (`GOOS=windows go vet`) and unit-tested in its pure parts (`windows.go`: environment block, command line); never run against a real conhost** — nobody on this project has a Windows machine.
- **The tools shell is per platform, and cmd.exe gets its command line verbatim.** `tools.Shell()` is `/bin/sh` on Unix and `%COMSPEC%` on Windows; `tools.ShellArgs(command)` is `["-c", …]` or `["/S", "/C", …]`. cmd.exe does not read a command line by the C runtime's rules — Go's composition would turn a `"` inside the command into `\"` — so on Windows the line is written as `"cmd.exe" /S /C "<command>"` and handed to `exec` through `SysProcAttr.CmdLine`, and `terminal.windowsCommandLine` does the same for a terminal-output tool. `tools.Shell` was a constant and is now a function: a minor version bump.
- **Stopping a command stops its tree on both platforms.** Unix: a process group (`Setpgid`) killed by negative pid. Windows: a job object created with `KILL_ON_JOB_CLOSE`, assigned after the process starts (a child spawned in those first milliseconds escapes — `exec.Cmd` cannot start a process suspended), terminated by `TerminateJobObject`. Both behind the `group` interface in `tools/run.go`.
- **Two direct dependencies plus one**: `tcell/v2`, `BurntSushi/toml`, and `golang.org/x/sys` for the pty ioctls and, on Windows, the pseudo-console, process and job-object calls. The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written. Do not add another without saying why.

The decisions inherited from turbo-go about the *editor's behaviour* — the event loop being state-driven, upward communication by function field, dialogs being asynchronous, undo merging runs, `ReplaceRange` being the single mutation path, the terminal emulator's deliberate boundaries — are unchanged and are recorded in turbo-go's own `.memory/history.md`, where they were made.
- **`jsonrpc` is `lsp`'s protocol layer, extracted when a second protocol arrived.** The Language Server Protocol and the Agent Client Protocol differ at that level in exactly one thing: how a message is marked off from the next — a `Content-Length` header for one, a newline for the other. That is the `Framer` interface; everything above it is shared. Rejected: a near-copy of `Conn` inside `acp`, which is 250 duplicated lines and two places for one bug.
- **A JSON-RPC request may be answered later.** `RequestFunc` takes a `*jsonrpc.Request` and returns nothing. Every question a language server asks can be answered where it arrives; `session/request_permission` cannot be, because the answer comes from a dialog somebody has to look at, and opening one belongs to the goroutine that draws. `Reply` is guarded by a `sync.Once` — a window closing tears down a dialog that may already have been answered, and two responses carrying one id desynchronise a peer that matches answers to requests by exactly that id.
- **The two id directions are separate spaces, and that is not theoretical.** `docker agent` really does send `id: 1` while a call of ours carrying the same id is in flight. A connection sharing one map would hand the peer's *request* to the caller waiting for an answer. `TestAPeersRequestIdDoesNotStealAnAnswerMeantForOurOwnCall` is what holds it.
- **An agent is a window, not a panel** — the project tree's argument, unchanged: a docked panel means the desktop growing reserved edges, and `fitInto`, the grow modes, maximising, tiling and cascading all having to respect them. It also makes "several agents at once" fall out rather than be designed.
- **One process and one session per window, started when the window opens.** Closing it is an unambiguous end and asks nothing — a conversation is a running process, not unsaved work, which is the bargain terminals already make. Rejected: one long-lived agent multiplexed across windows, which means deciding which window a `session/update` belongs to and what to do with a window whose session died while the process lived.
- **The permission dialog is opened from the event loop, never from the message.** Fourth instance of the same rule (autosave, the re-announcement, terminal redraws are the others): `PostEvent` may drop what does not fit, so an event may *cause* a turn but must never be the only thing carrying a fact. `Permission.Answer` and `Cancel` also write on a goroutine of their own, because writing blocks until the agent reads and an agent that has stopped reading must not freeze the editor on the keystroke that answers it. The same applies to `Session.Cancel`. **This was found by a test hanging, not by reasoning.**
- **Escape on a permission is the agent's own reject option, not a cancellation.** The turn is still running and the agent may carry on without what it asked for. Inventing an option id the agent does not know leaves it as stuck as saying nothing, so `RejectOption` prefers its `reject_once` and falls back to the last option it offered.
- **The agent reads the buffer, not the disk.** A file open with unsaved changes is answered from its buffer: the alternative is an agent reviewing the version you have just moved past, which is wrong precisely when you are most likely to be asking. Writes go into the buffer too, marked modified — an agent quietly rewriting a file under an open window would be the worst possible version of this. The cost, accepted: the agent sees text no other tool can see, which is already true of completion.
- **The conversation is a value the window merely draws.** `acp.Transcript` coalesces token-level chunks into entries, folds each `tool_call_update` onto the `tool_call` its id matches, and hands the window blocks that are either prose or code-in-a-named-language. It knows nothing about a terminal, which is what makes the drawing tests deterministic — a window drawn from a fixed transcript cannot race a live agent.
- **Only a fence decides that something is code.** No scanner is guessed at from the shape of the text; a fence naming a language nothing colours is drawn plainly. The tag is matched against `syntax.Registered()` rather than a list kept in `acp`, so an editor registering Rust colours a ```rust block with no change to the library. A handful of aliases (`sh`, `golang`, `js`, `yml`, `md`) are mapped, because a model writes them by habit.
- **Prose is wrapped; code is not.** A wrapped line of code would need its spans remapped onto the pieces, and half a line of Go under the line above reads worse than one that is simply too long. Editors clip code; they do not reflow it.
- **The agent window adds no theme keys.** A speaker's label is the keyword style, a thought the comment style, a tool call the type style, a failure and the editor's own notices `diagnostic.error`, and code whatever its scanner says. The project tree needed keys of its own because it would otherwise have borrowed a colour chosen against a *dialog* background; nothing like that is true here, since an agent window's body is `window.body`, which is what the syntax styles are already chosen and contrast-tested against. Eleven themes therefore colour agent windows correctly untouched. Given up: a theme cannot make thoughts quiet without making comments quiet. `agent.*` keys can be added later if that turns out to matter.
- **`acp.toml` is read from the user's directory and then the project's, and the project wins by name** — the snippets rule, not the tools one. An agent is a program you have installed and configured, which is a fact about you rather than about one repository.
- **A key the format does not define is refused, and the whole file with it.** A misspelt `comand` silently ignored leaves an agent that cannot start and a file that looks right; a half-loaded menu offering three of your five agents is worse than an error naming the line.
- **What the agent calls itself is kept but not shown.** `docker agent` reports `"docker agent"` — the runtime, not the assistant — while the menu entry is the name the user chose. A window titled "Bob (llama.cpp)" whose messages are signed "docker agent" is two names for one thing. `AgentInfo()` keeps it for the status dialog.
- **`NewSession` takes a stream, not a command**, exactly as `lsp.NewClient` does, which is what lets the whole client be driven from a test against an agent in the same process over `net.Pipe`. `Start` is the thin layer that builds that stream out of a child process's pipes.
- **An unknown `session/update` is ignored and counted, never refused.** The protocol grows, and an editor that stopped talking to an agent because it learnt a new message would be wrong more often than it was right. The count is in **Agent ▸ Agent status**, so "the protocol moved on" is visible rather than silent.
- **The spinner is a function of the clock, not a counter.** `acp.Spinner(now)` — so nothing has to be reset when a turn begins, two windows thinking at once turn in step, and a test asserts on a frame without waiting for one. Drawing from the clock means something else must *cause* the redraw, so a session running a turn wakes the loop at the spinner's rate; that is allowed to be a ticker because a dropped tick cannot strand anything. The window **title** deliberately does not animate: it is also what the window list and the Alt-digit menu show.
- **A command is a text prompt, not a method, and the picker is a convenience over that fact.** `available_commands_update` lists what the agent answers to; the client sends `/web agent client protocol` as one text block, exactly as Zed does. So the list `/` opens in the box is recomputed from `Session.Commands()` and the word under the cursor on every key and every frame — nothing stored, nothing to keep in step — and `/` anywhere but the first character of a single-line box is a character. Rejected: a menu built from the list, which would sit far from the box, need rebuilding on every update, and still end by typing `/web ` into it.
- **`Enter` on the picker completes an unfinished word and sends a finished one.** A word that already reads exactly as a command has nothing left to complete; making `Enter` complete it anyway costs a keystroke per command for nothing. `Tab` always completes. `Esc` closes the list *until the text changes* — otherwise it would reopen on the next frame.
- **A mention replaces its name in the text with the file.** `@app/menus.go` becomes a `resource` block (uri, mimeType, full text, read through the same path as `fs/read_text_file`) when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise or when the file cannot be read — never nothing. Text blocks go either side. The transcript keeps the line as typed. The name has to end the word (`@main.go` does not name `main.gopher`) and has to be in the list the editor supplied, so an e-mail address stays text. `View.Files func() []Mention` is the seam: nil means `@` is only a character; the app supplies a walk of the project (`.git` skipped, 5 000 files max) done afresh each time the list opens and cached while it is open.
- **Media types are listed, not looked up.** `mimeOf` has its own table for the family's languages and falls back to `mime.TypeByExtension`; the system table differs between Linux and macOS for `.go`, and an agent keying on the type deserves one answer from every copy of the editor.
- **Copying goes to two clipboards.** The editor's own (`Shift-Ins` pastes it into a file here) and the system's, through the terminal's OSC 52 (`Ctrl-V` pastes it anywhere else). Nothing verifies the second: the sequence has no reply, a terminal may refuse it for security, and a message promising something that did not happen is worse than one that stays quiet. The status bar says how many lines were copied, which is true either way.
- **Selection in a conversation is by whole lines, and with none, `Ctrl-C` copies the region under the cursor.** Nothing in a conversation is edited, so half a line is never what somebody means, and whole lines keep a code block's indentation. A `Line` carries a `Region` — one fenced block, one passage of prose, one tool's output — and a speaker's label and a tool call's heading are regions of their own, which is what keeps `‣ Bob (llama.cpp)` out of a block pasted into a source file. **That last part was found, not designed**: the first version copied the label, caught by copying from the real binary and reading the OSC 52 payload back off the wire.

## Build, test, run

```bash
make test        # the whole suite — the single documented command
make version     # the version this checkout would publish
make race        # the same under the race detector
make cover       # statement coverage per package
make check       # fmt, vet and test
make help        # every target
```

Quality gate, separate from the tests:

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

## State as of 2026-09-01

- **Tool parameters implemented.** A `{{label}}` in a tools command opens a box before the command runs. `tools/placeholder.go` parses and fills; `app`'s `ParametersDialog` asks. Verified in a real pty against both editors.
- **v0.1.0 is released.** The tag is on `main` at `f1f0375`, the release page is at `https://codeberg.org/turbo-editors/turbo-core/releases/tag/v0.1.0`, and `go get codeberg.org/turbo-editors/turbo-core@v0.1.0` works from the module proxy. Both editors `require` it with **no active `replace`**, verified by running their suites against the real published module.
- **HEAD is one commit past the tag.** `bc4a464` holds `02-release.publish.sh`, its eight tests, and the release how-to's rewrite — so **none of that is in v0.1.0**. It changes no API, so nothing depending on the library is affected; the only visible consequence is that the v0.1.0 release page's documentation links, which are pinned to the tag, show the how-to as it was before the publish script existed. Fold it into the next tag rather than moving v0.1.0.
- **Two release scripts.** `01-release.tag.sh` runs `make check`, refuses a tag taken locally or on origin, refuses a `go.mod` with a `replace`, and tags only after pushing. `02-release.publish.sh` creates the Codeberg release page for a tag that is already there, building its JSON with jq, telling the HTTP statuses apart, and taking `--dry-run`. There is deliberately no 03 or 04: those build and attach binaries, and a library has none. `release.env` and `turbo-core.token.env` are gitignored by `*.env`.
- **Extracted and green.** Sixteen packages, whole suite passing, and passing under `-race`.
- **Eight languages are coloured.** YAML, XML and Dockerfiles were added on 2026-09-01, and `Definition` grew a `Filenames` field so a file with no extension and no shebang — a `Dockerfile` — can be recognised. `LanguageOf` now looks at the extension, then the name, then the first line.
- **Quality gate: PASS.** 0 errors, 0 warnings, 0 smells, total complexity 2306 (run 29, 2026-09-17).
- **Windows terminal windows and tools (2026-09-17)**: `terminal/pty_windows.go`, `terminal/windows.go`, `tools/shell_{unix,windows}.go`, `app` rewired; cross-compiled for windows/amd64 and windows/arm64 and darwin, `-race` green on `terminal` and `tools`. Uncommitted, on `main`, unreleased — **needs a tag (v0.9.0: `tools.Shell` const → func) and a re-pin in every editor** before any of them gains it; their docs already describe it.
- **Six editors are built on it** — turbo-go, turbo-rust, turbo-python, turbo-moonbit, turbo-golo and turbo-js — all in repositories beside this one, depending on it from the module proxy with no active `replace`. turbo-js was added on 2026-09-16 and pins `v0.8.0`; like turbo-python, turbo-moonbit and turbo-golo before it, it needed **no change to the library**, which is the strongest evidence the seam holds that this project has. It is the first editor to **replace a built-in scanner**: it registers its own JavaScript under `syntax.LanguageJavaScript`, which `Register` allows by design, and adds JSON beside it.
- **Documentation**: 12 pages × EN + FR under `docs/`, a `README.md` per package, and a drawio diagram generated from `go list` and verified against it.
- **The tutorial has been run start to finish**, verbatim, in a throwaway module: it builds, the menu bar comes up with the new editor's own menu on it, and the colours land. Both its intermediate states compile.

## Not yet established

- **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.
- **The `/` and `@` pickers have only been driven by tests.** Commands, hints, embedded and linked mentions are all asserted on the wire against the fake agent, and the popup on a simulation screen, but no real agent has announced a command to this client yet — `docker agent` was not available in the sandbox on 2026-09-15 — and the user's own `mini-me` agent (`mm -acp`, which Zed discovers commands from) has never been pointed at an agent window. Whether its `available_commands_update` arrives before or after `session/new` returns, and whether it embeds context, are both unknown.
- **`release_test.go` fails in this sandbox for a reason that is not the code.** It copies the module with `cp -r`, and the sandbox's filesystem returns NUL bytes from `cp` for some recently written inodes (`acp/picker.go`, `app/agent_files.go` on 2026-09-15). Recreating the files at fresh inodes did **not** clear it this time. `cat`, `git`, `gofmt`, `go build` and `go test ./acp ./app` all read the right bytes.
- **`fs/read_text_file` and `fs/write_text_file` have never been exercised by a real agent.** They are covered by tests against the fake one, and `docker agent` did not call them: its filesystem toolset is its own, server-side, so it never asks the client. An agent that *does* use the client capability is the untested case.
- **Long conversations are unmeasured.** The window re-wraps the whole transcript every frame. It was fine at a few dozen entries; nothing has been profiled, and `MaxEntryBytes` (a megabyte) is a guess rather than a measurement.
- **`session/load`, `authenticate` and the terminal capability are not implemented**, deliberately, and each for a reason written down in `docs/*/explanation/agent-windows.md` in turbo-go.


- **No CI.** There is no pipeline configuration in the repository.
- **Windows and macOS are untested.** `terminal/pty_darwin.go` compiles and passes vet but has never been run. **The Windows pseudo-console path (added 2026-09-17) has never been started against a real conhost**: it compiles for amd64 and arm64, passes `GOOS=windows go vet`, and its pure parts are unit-tested everywhere, but `F8`, a terminal-output tool, `Ctrl-C`, resizing and closing have not been exercised on Windows. The five-step protocol is in `docs/*/how-to/use-a-terminal.md` of every editor and in `handoffs/2026-09-17-windows-terminal.md`. Two things are most likely to be wrong first: the order of `TerminateProcess` / `ClosePseudoConsole` / pipe closes in `Close`, and conhost's start-up sequences (cursor-shape, `?9001h`) meeting an emulator that has only ever read Unix shells.
- **The extension point has been exercised by seven editors** — Go, Rust, Python, MoonBit, Golo, JavaScript, and the Zig editor the tutorial builds — but all of them were written by the same hand. Nobody outside has tried to add a language.
- **Two things the sixth editor found in the library, neither fixed here.** `lsp.Client.DidOpen` sends `languageId: "go"` for every editor (`lsp/client.go`); tsserver decides by file extension so nothing broke, but it is a hardcoded language in a string a server reads, and the profile is where it belongs. And the client reads only `publishDiagnostics`: TypeScript 7's native server (`tsc --lsp --stdio`) offers diagnostics **pull-style** (`textDocument/diagnostic`) and publishes none, so with it every gutter stays blank — which is why turbo-js names `typescript-language-server` with `typescript@6` instead. Learning to pull is a library cycle of its own.
- **The Save-As announcement fix (2026-09-18) has not been driven against a real gopls.** It is covered by five tests against the fake server, each verified by breaking the code it covers, but nobody has yet started the real editor, typed into an Untitled window, saved it and completed. The other first-launch suspect the user's report could also fit — gopls's cold cache on its very first run — remains unmeasured.
- **No performance measurement.** Behaviour on a file of tens of thousands of lines is unknown.
- **`profile.Templates`' formatting contract is documented, not enforced.** A template with the wrong number of verbs produces `%!s(MISSING)` in somebody's project; each editor tests its own.