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 rickub.com/turbo-editors/turbo-core. Go 1.26.5. Remote: ssh://git@rickub.com/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.Profileis 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.Registerwrites into package-level state, the shape the standard library gives the same problem inimage.RegisterFormat. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry througheditor.View,syntax.Cacheand every call site. It is not safe from two goroutines, and there is no reason for it to be: registration belongs inmain, beside the flags.- The
Classset 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-Ydeletes a line; redo moved toCtrl-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-Zwas not available to move redo to: a terminal delivers it as plainCtrl-Z. This is the one user-visible key change the project has made.Ctrl-Nopens 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
IsWordRunedefines, 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 sendsButton1for every event of a drag, and counting those turns a slow drag into a double click. editor.Viewhas an injectable clock, asapp.Appdoes, 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.
completionandhover;definition,typeDefinition,implementationandreferences, which share one answer shape and therefore one decoder;documentSymbolandworkspace/symbol. PluspublishDiagnostics, 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.
GoToDefinitiontooklocations[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.
documentSymbolanswers nested or flat,workspace/symbolmay answer a location with no range. The two document shapes are told apart byselectionRange, not bychildren— 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. editordoes not importlsp, and will not. A gutter mark is aneditor.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 + 1wide, 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.goholds 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, formerlydiagnosticKey), soKnowsdoes 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), andannounceOpenDocumentsruns once — so when Save As finally names it, sending onlydidSaveleft that window without LSP until a restart, which is how a user found it ("first launch: no LSP; save, quit, relaunch: works").announceSaved, inafterSaveso both save paths get it, sendsdidOpenfor a path the server does notKnowsanddidSaveotherwise; andsaveremembers the buffer's path beforeSaveAsrewrites it, closing the old document on a genuine rename (renamed, compared throughpathKey, so a change of spelling is not one). Rejected: announcing fromBuffer.SaveAsitself, which would put the language server underneath the text type. - Saving the project's settings file re-applies it.
UseSettingswas called once, frommain, so editingsettings.tomlin the editor did nothing until a restart.reapplySettingsruns after every successful write and matches on the path, not ona.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-themeflag is the more explicit statement for its session. A file that no longer parses saysSaved, 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.
afterSaveis 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
projectFilevalue 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.commentis 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 coloursservices: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.27is a key and one value;url: http://xis 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.Filenamesmatches the stem as well as the whole name. ADockerfilehas no extension and no shebang, and listing"Dockerfile"also recognisesDockerfile.devwithout naming every variant a project invents. An all-extension name like.gitignorehas an empty stem and matches nothing — otherwise the stem rule becomes a wildcard.- The scanner toolkit is exported —
LineScannerand 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. LineIndexexists for a scanner built on a tokeniser. Go's goes throughgo/scannerand works in byte offsets; every other scanner works a line at a time. The library supports both shapes because of that one scanner.themetakes auserDir string;settings,snippets,toolsandlsptake aprofile.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.Loadfills in the default menu, so aToolthat came out of it always has aMenu.tools.DefaultMenuused 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.ProjectRootwalks up looking for the profile'sRootMarkers. 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 inos.Getwd()with no walk, because a module has a real boundary and "the project" does not.app.Tick,app.Handle,app.Renderandapp.ActiveVieware 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.
aboutTexttakesprofile.Languagenow. 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.NewClienttakes 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.Definesis satisfied by inheritance, so a theme omitting a key silently shows a colour Turbo Classic chose for its navy background;TestEveryEmbeddedThemeSetsEveryKeyItselfcloses 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.
monochromebecamemonochrome-darkwhenmonochrome-lightjoined it, andretiredNamesinload.gomaps the old name onto the new one. It is resolved after the user's directory, so somebody's ownmonochrome.tomlstill wins; it is not listed byAvailable, 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#121212and 3.40:1 on the#eeeeeepage. 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.
darculaandintellij-lighttake 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 inpalettesWeDoNotOwn: 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}'andfind . -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.toolsparses and fills;appdraws 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 unknownoutputalready follows. - A parameters box that will not fit is refused with a message.
app.MaxParameterFieldsis 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.Sessionholds a smallchildinterface:session_unix.gois the/dev/ptmx+exec.Cmdpath,pty_windows.gocreates two pipes and a pseudo-console and starts the shell withCreateProcessby hand — Go'sexeccannot carry thePROC_THREAD_ATTRIBUTE_PSEUDOCONSOLEattribute — andpty_other.gostill returnsErrUnsupportedfor 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:UpdateProcThreadAttributeis called through aLazyProcbecause the attribute's value is the handle itself and x/sys's wrapper wants anunsafe.Pointer(the conversiongo vetflags); 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 theio.EOFthe 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/shon 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 toexecthroughSysProcAttr.CmdLine, andterminal.windowsCommandLinedoes the same for a terminal-output tool.tools.Shellwas 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 withKILL_ON_JOB_CLOSE, assigned after the process starts (a child spawned in those first milliseconds escapes —exec.Cmdcannot start a process suspended), terminated byTerminateJobObject. Both behind thegroupinterface intools/run.go. - Two direct dependencies plus one:
tcell/v2,BurntSushi/toml, andgolang.org/x/sysfor 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.
-
jsonrpcislsp'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 — aContent-Lengthheader for one, a newline for the other. That is theFramerinterface; everything above it is shared. Rejected: a near-copy ofConninsideacp, which is 250 duplicated lines and two places for one bug. -
A JSON-RPC request may be answered later.
RequestFunctakes a*jsonrpc.Requestand returns nothing. Every question a language server asks can be answered where it arrives;session/request_permissioncannot be, because the answer comes from a dialog somebody has to look at, and opening one belongs to the goroutine that draws.Replyis guarded by async.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 agentreally does sendid: 1while 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.TestAPeersRequestIdDoesNotStealAnAnswerMeantForOurOwnCallis 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/updatebelongs 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):
PostEventmay drop what does not fit, so an event may cause a turn but must never be the only thing carrying a fact.Permission.AnswerandCancelalso 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 toSession.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
RejectOptionprefers itsreject_onceand 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.Transcriptcoalesces token-level chunks into entries, folds eachtool_call_updateonto thetool_callits 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 inacp, 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 iswindow.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.tomlis 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
comandsilently 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 agentreports"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. -
NewSessiontakes a stream, not a command, exactly aslsp.NewClientdoes, which is what lets the whole client be driven from a test against an agent in the same process overnet.Pipe.Startis the thin layer that builds that stream out of a child process's pipes. -
An unknown
session/updateis 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_updatelists what the agent answers to; the client sends/web agent client protocolas one text block, exactly as Zed does. So the list/opens in the box is recomputed fromSession.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/webinto it. -
Enteron 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; makingEntercomplete it anyway costs a keystroke per command for nothing.Tabalways completes.Esccloses 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.gobecomes aresourceblock (uri, mimeType, full text, read through the same path asfs/read_text_file) when the agent declaredpromptCapabilities.embeddedContext, aresource_linkotherwise 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.godoes not namemain.gopher) and has to be in the list the editor supplied, so an e-mail address stays text.View.Files func() []Mentionis the seam: nil means@is only a character; the app supplies a walk of the project (.gitskipped, 5 000 files max) done afresh each time the list opens and cached while it is open. -
Media types are listed, not looked up.
mimeOfhas its own table for the family's languages and falls back tomime.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-Inspastes it into a file here) and the system's, through the terminal's OSC 52 (Ctrl-Vpastes 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-Ccopies 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. ALinecarries aRegion— 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. -
Paths are canonical, not merely absolute (2026-09-19, v1.0.1).
lsp.CanonicalPath—filepath.Absthenfilepath.EvalSymlinks, falling back to the deepest existing directory for a file not yet on disk — is whatlsp.PathToURIsends the server, whatapp.pathKeykeys documents and diagnostics by, and whatapp.windowForcompares when a location the server sent back has to land on a window already open. Reason: moon-lsp canonicalises a package's files, so a document announced as/var/folders/…/main.mbt(macOS:/var→/private/var) belonged to no package — no completion about the buffer's types, diagnostics published under/private/var/…that matched no open buffer. Turbo MoonBit's suite, green on Linux, failed both ways on its first run on a Mac; reproduced here withTMPDIRunder a symlink, and with an LSP probe. Falsified:TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALinkfails on the oldpathKey. Rejected: fixing it in the editor's test by resolvingt.TempDir()— that would hide the defect from every user whose files sit under a link.
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.goparses and fills;app'sParametersDialogasks. Verified in a real pty against both editors. - v0.1.0 is released. The tag is on
mainatf1f0375, the release page is athttps://rickub.com/turbo-editors/turbo-core/releases/tag/v0.1.0, andgo get rickub.com/turbo-editors/turbo-core@v0.1.0works from the module proxy. Both editorsrequireit with no activereplace, verified by running their suites against the real published module. - HEAD is one commit past the tag.
bc4a464holds02-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.shrunsmake check, refuses a tag taken locally or on origin, refuses ago.modwith areplace, and tags only after pushing.02-release.publish.shcreates the Rickub 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.envandturbo-core.token.envare 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
Definitiongrew aFilenamesfield so a file with no extension and no shebang — aDockerfile— can be recognised.LanguageOfnow 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,apprewired; cross-compiled for windows/amd64 and windows/arm64 and darwin,-racegreen onterminalandtools. Uncommitted, onmain, unreleased — needs a tag (v0.9.0:tools.Shellconst → 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 pinsv0.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 undersyntax.LanguageJavaScript, whichRegisterallows by design, and adds JSON beside it. - Documentation: 12 pages × EN + FR under
docs/, aREADME.mdper package, and a drawio diagram generated fromgo listand 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.
State as of 2026-09-19
- v1.0.0 is released under the new module path
rickub.com/turbo-editors/turbo-core, by01-release.tag.shand the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release. - v1.0.1 is ready to tag (
release.envsays so): the canonical-path fix above,lsp.CanonicalPathexported, three new tests (lsp×2,app×1),app/fakelsp_test.gorecords the lastdidOpen. Every editor should re-pin to it before releasing; Turbo MoonBit must, or its suite fails on macOS.
Not yet established
-
Agent windows have been driven, but only from a script. The whole path was exercised against a real
docker agentv1.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,Tabbetween 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 agentwas not available in the sandbox on 2026-09-15 — and the user's ownmini-meagent (mm -acp, which Zed discovers commands from) has never been pointed at an agent window. Whether itsavailable_commands_updatearrives before or aftersession/newreturns, and whether it embeds context, are both unknown. -
release_test.gofails in this sandbox for a reason that is not the code. It copies the module withcp -r, and the sandbox's filesystem returns NUL bytes fromcpfor some recently written inodes (acp/picker.go,app/agent_files.goon 2026-09-15). Recreating the files at fresh inodes did not clear it this time.cat,git,gofmt,go buildandgo test ./acp ./appall read the right bytes. -
fs/read_text_fileandfs/write_text_filehave never been exercised by a real agent. They are covered by tests against the fake one, anddocker agentdid 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,authenticateand the terminal capability are not implemented, deliberately, and each for a reason written down indocs/*/explanation/agent-windows.mdin turbo-go. -
No CI. There is no pipeline configuration in the repository.
-
Windows and macOS are untested.
terminal/pty_darwin.gocompiles 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, passesGOOS=windows go vet, and its pure parts are unit-tested everywhere, butF8, a terminal-output tool,Ctrl-C, resizing and closing have not been exercised on Windows. The five-step protocol is indocs/*/how-to/use-a-terminal.mdof every editor and inhandoffs/2026-09-17-windows-terminal.md. Two things are most likely to be wrong first: the order ofTerminateProcess/ClosePseudoConsole/ pipe closes inClose, 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.DidOpensendslanguageId: "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 onlypublishDiagnostics: 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 namestypescript-language-serverwithtypescript@6instead. 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 176 177 178 179 180 181 182 |
|