turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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 28d59854361aeda8541d853093e732126f3d7bff · k33g · 15h ago
README.md · 192 lines · 20.2 KBmarkdown
Blame HistoryOpen raw

app

Assembles the editor: the screen, the desktop of windows, the menu bar, the status bar, the modal stack and the completion popup — and the routing that decides which of them sees each key and click.

Everything below this package is reusable on its own; this is where the decisions about this editor live.

Event routing

One order, front to back, for both keys and clicks:

completion popup  →  modal dialog  →  open menu  →  focused terminal  →  menu bar  →  global shortcuts  →  desktop

keyLayers() is that list, in that order; each layer returns whether it consumed the event. A modal dialog consumes everything, including keys none of its controls wants, so nothing behind it can be typed into or dragged while a question is waiting.

Two layers are exceptions, and both are deliberate:

  • The completion popup consumes the arrows, Enter, Tab and Escape, but lets printable characters through — which is what lets the user keep typing while the list narrows itself.
  • A focused terminal sits above the global shortcuts, the one place the order is inverted. A shell wants Ctrl-C, Ctrl-W and Ctrl-F, and the editor would otherwise take all three. terminalTakesKey gives it everything except what editorOwnedKey reserves — the function keys, Alt-X, and Alt-0Alt-9 — which are the only way out of a full-screen program.

Dialogs are asynchronous

There is no nested event loop. pushModal(dialog, onClose) puts a dialog in front, and settleModals — which runs after every event — pops the ones that have been answered and calls their callbacks. A dialog that opens another one therefore just works, with no re-entrancy anywhere.

In the Open / Save As box, the Name field and the list are kept in step: ListBox.OnSelect writes the highlighted entry into the field, so the field always says what OK is about to act on, and confirm falls back to the highlight when the field is empty. Without that wiring the two controls are independent, and OK on a freshly opened dialog does nothing at all — the user has highlighted a file, the field is still empty, and there is no path to act on.

Terminal windows are windows

terminal provides the widget; this package decides where a shell starts, what closes it, and which windows are terminals at all.

a.terminals maps a window to its view, and is the answer to "is this a terminal?" everywhere it matters: editorViewOf returns no editor for one, closeWindow short-circuits to closeTerminal rather than asking about unsaved work, and Quit calls closeTerminals so no shell outlives the editor.

A new terminal starts in the directory of the file in front — where go build and git want to be — and its title is refreshed on every turn of the event loop, because a program inside it can rename itself from a goroutine nothing else watches.

Agent windows are windows too

acp provides the protocol client and the widget; this package decides which agents exist, starts one per window, and answers the questions an agent asks of its client.

a.agents maps a window to its session, and is the answer to "is this an agent?" everywhere it matters — exactly as a.terminals is for a shell. Closing one asks nothing: a conversation is a running process, not unsaved work.

Two things are this package's rather than acp's, and both are about the editor around the conversation:

  • The permission dialog. session/request_permission arrives on the session's reading goroutine; recordPermission puts it in a queue and wakes the loop, and askNextPermission opens the box on the next turn. One at a time — pendingPermissions.asking holds the rest back — because two agents asking at once would stack two modals over each other. Escape means 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.
  • The file methods. readForAgent answers from the buffer when the file is open and modified, so the agent sees the edit you just made rather than the version on disk. writeForAgent puts the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable with Ctrl-Z and yours to keep with F2. Both take agentFiles, because they run on a session's goroutine while the event loop may be writing the same buffers.

Project settings and automatic saving

A project's settings file is read by main and handed here through UseSettings, which applies autosave and remembers the path. The theme is not applied here: main resolves it first, because a -theme flag outranks the project's choice and only main knows whether one was given.

Saving the settings file re-applies it. UseSettings used to be the only call, made once from main, so editing settings.toml in the editor did nothing until the next start-up — which is what a user reported. reapplySettings now runs after every successful write and, when the path written is this project's settings file, re-reads it and applies autosave. It matches on the path rather than on a.settingsPath, because a project that had no settings file has nothing remembered and creating one has to count. A file that no longer parses says Saved, but not applied: … and the previous values stay: that is a third outcome, distinct from "saved" and from "cannot save", and the only one that leaves the editor behaving unlike the file on the screen.

That step lives in afterSave, which is the tail both save paths share. The File menu's save and automatic saving differ only in how they report a failure, and a step added to one used to be missing from the other — this one would have been.

The theme is deliberately not re-applied on save: Options ▸ Theme is the live path for it and already writes the choice back here, and a -theme flag is the more explicit statement for the session it was given in.

a.settingsPath is the whole of the write-back rule. It is empty in a project with no settings file, and picking a theme then changes nothing on disk — a theme tried for ten seconds must not put a directory into someone's repository. Once the file exists, rememberTheme rewrites one key of it in place.

Autosave is state, nudged by a timer — not driven by one. noteEdit sets a deadline; saveDueDocuments checks it at the top of every turn of the event loop, exactly as announceOpenDocuments does. A time.AfterFunc calls wake so the loop turns at all while nothing is happening, but that wake-up is a PostEvent, and PostEvent drops what does not fit. A dropped nudge costs a late save; a design where the timer did the saving would lose it.

Two consequences worth knowing: a failed save clears the deadline before writing, so a read-only file is tried once per edit rather than forever; and it reports on the status bar, never in a dialog, because a modal reappearing every two seconds is worse than the problem it describes.

The project tree is one window, held by hand

filetree provides the widget; this package decides where it is rooted, that there is only one, and when it is re-read.

a.treeWindow and a.treeView are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. ProjectTree raises the existing window instead of opening another, the same way opening an already-open file raises its window.

The tree is rooted at os.Getwd() — the same rule the settings file follows, so "the project" means one thing throughout the editor. refreshTree runs after every save, which is the one moment the editor knows the project changed; everything else is the user's F5.

The Go menu, and putting files back afterwards

tools holds the commands; this package runs one in a terminal window and deals with what it did to the files.

A command may ask for values first. A {{label}} in it opens a box titled after the tool, one field per label, before anything runs; Escape runs nothing. What was typed is remembered for the session, per tool, and never written to disk. MaxParameterFields says how many fit on a screen, and a tool asking for more is refused with a message rather than given a box whose OK button is off the bottom.

Where the output goes is the tool's choice, from the project's tools.toml. A popup is the default: it opens at once and fills in as the command runs, its title carrying — running and then — ok or — exit n, and closing it stops a command still going. A terminal window is what an interactive or long command wants — Run is the one default that asks for it — and an editor window is for output to work through with Ctrl-F.

The popup is refreshed from tick, not from the reading goroutine, which may not touch a dialog.

Format rewrites files on disk, including the one in front. Without anything further the editor would sit on a stale copy and the next F2 would write the unformatted version back over gofmt's work — the feature quietly undoing itself. So reloadAfterTools re-reads every open file with no unsaved changes, at the top of the event loop, and says on the status bar how many. A file with unsaved changes is left alone and counted: the edit and the formatter genuinely disagree, and the editor is not in a position to decide.

The exit is noticed on a goroutine that may not touch a buffer, so it sets a.toolsRan and the loop does the work.

tick is that loop turn, extracted so a test can take one. Everything in it is state-drivenannounceOpenDocuments, refreshRunningTool, reloadAfterTools, saveDueDocuments, refreshTerminalTitles — for the same reason each time: the only way to wake this loop from another goroutine is PostEvent, which drops what does not fit, so the wake-up may be lost and the state must not be. Tests call tick, not the individual step, or removing a step from the loop would leave them passing.

A tool's menu key is a free-form name, so the set of menus on the bar comes from a file that changes while the editor runs. Menu.OnOpen cannot cover that — it refills one menu's items, and a menu that does not exist has no OnOpen to call — so allMenus builds the whole bar and refreshToolMenus replaces it through ui.MenuBar.SetMenus.

Rebuilding is guarded by the tools file's size and modification time, kept in a.toolsStamp. Parsing the file on every turn of the loop would work and would also be work done for nothing on every keystroke; one stat per turn is what it costs instead. The stamp is taken before the bar is built, so a file written between the two is picked up on the next turn rather than missed.

Hot keys for those menus are assigned here, not read from the file, because only the bar knows which letters are taken. hotKeyLabel marks the first free letter of the name and reserves it; tildes written into the name are kept when the letter is free and dropped when it is not. That asymmetry is the point: the bar answers the first menu matching a key, so a clash makes one menu silently unreachable — the bug Snippets and Search already produced once, with every test passing. A name whose every letter is taken gets no hot key at all, which F10 and the mouse still reach.

Go stays a fixed menu rather than becoming another name from the file: it holds Create tools file, which has to be reachable in a project that has no tools file.

createProjectFile is the one place the three project files are created from — settings, snippets and tools all do the same dance, and it is written once.

The About box says only what the build recorded

app.Version is gone. The number comes from version, which takes it from the linker's stamp, then from the Go build system, then gives up and says unknown — because a constant in the source is accurate the day it is written and wrong for every commit after it, and an About box is exactly where a number nobody set gets believed.

aboutText(name, language, info, themeName) is a pure function, so what the box says is tested without opening one. It omits a line whose fact is empty rather than showing a blank one: a binary from go install …@v0.2.0 knows its version and nothing else, and Commit: with nothing after it says only that the editor failed to fill it in.

The language comes from the profile rather than from this package's own prose. It did not, and the sentence "A Turbo C-style editor for Go" was drawn by Turbo Rust — the shape of mistake this whole package is meant to make impossible, in the one string nobody thought of as a setting.

The language server is optional by construction

Language wraps the whole gopls conversation. When there is no server — not installed, or failed to start — every method is a no-op, not an error, so the rest of the app never has to ask whether a language server exists. Its state shows on the status bar; editing and colouring carry on regardless.

Diagnostics arrive on the connection's read loop, off the main goroutine, so Language posts an interrupt event to wake the event loop rather than drawing from there.

What is here

File
app.go The App: layout, drawing, routing, the modal stack
actions_file.go New, open, save, close, quit, and where a new window goes
actions_edit.go Undo, redo, clipboard, select all
actions_view.go Tile, cascade, maximise, themes, line numbers, help, About
terminals.go Opening, closing and tracking terminal windows
agents.go Opening, closing and tracking agent windows; the file methods; the status report
agent_permissions.go The queue of agents waiting for an answer, and the dialog that gives one
agent_files.go The project's files, as the @ picker in an agent window lists them
tree.go The project tree window: opening it, closing it, refreshing it
snippets.go The Snippets menu, and inserting one
gotools.go The Go menu: running a command in a terminal, and re-reading what it rewrote
toolmenus.go The menus the tools file asks for, and the hot keys they get
autosave.go The idle deadline and the saving it triggers
project.go The project settings file: creating, opening, writing the theme back
menus.go The menu bar and status bar, in one readable place
dialogs.go File browser, message, confirm, prompt, find, choice
complete.go Asking for a completion and accepting one
completion.go CompletionBox, the popup itself
language.go Language, the optional gopls conversation

File New · Open · Save · Save as · Close · Exit
Edit Undo · Redo · Cut · Copy · Paste · Select all
Search Find · Find next · Find previous · Go to line · Go to definition
Run Completion · Describe symbol · Language server status
Options Theme · Line numbers · Create project settings · Project settings
Window Next · New terminal · Project tree · Tile · Cascade · Maximise · List
Snippets built from the project's snippets file · Create snippets file
Agent built from the project's agents file · Cancel turn · Create agents file · Agent status
Go built from the project's tools file · Create tools file
Any menu the tools file names, between Go and Help, built from the tools that named it
Help Keyboard · About

Items that need a file open say so through Enabled, so the menu greys them out instead of doing nothing when chosen.

Tests

The whole application is exercised through tcell.SimulationScreen — layout, routing, dialogs, drawing — with only the terminal itself replaced.

The terminal-window tests pin $SHELL to /bin/sh and skip themselves where there is no /dev/ptmx, so they neither depend on the developer's login shell nor fail on a platform that has no pseudo-terminals.

make test
go test ./app/

The project's three files behave alike

settings.toml, snippets.toml and tools.toml all answer the same three questions — has the project got one, create it, open it — so the behaviour is written once and the differences are a projectFile value each: a title, how the file is named to a person, where the item that writes it lives, and two functions for existence and path.

The rule the menus enforce is that exactly one of each pair is ever available: you can create the file you have not got, and open the one you have. HasProjectTools and its two siblings decide it, and not inverts one for the create item. Before this, choosing create twice opened the file instead — defensible, and not what anybody expected from an item that says "create".

Both branches stay reachable from the API, because a caller that is not a menu has no greying-out: creating over an existing file still reports Already there, and opening a file that is not there still says where the item that makes it lives.

Everything asked of the language server is in one menu

Describe symbol lived in Run and Go to definition in Search, and neither is where a reader looks for "what else knows about this name". They are in Code now, with the four questions that answer with places and the two that answer with symbols. Their keys did not move.

Every item acts on the cursor, not a selection. Almost every request in the protocol takes a position rather than a range, so requiring a selection first would be an invented step that answers nothing extra.

showLocations is where the four location questions end up, and it has four outcomes rather than two. A server that is not ready says so, because "nothing found" and "I cannot answer yet" look identical to a user and only one of them is worth waiting out — it is the most confusing way completion fails, and this would have inherited it for free. One answer jumps, several offer the list: GoToDefinition used to take locations[0] and throw the rest away, so an interface with four implementations sent you to one of them, chosen by the server's ordering.

A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.

Diagnostics are keyed by absolute path

They arrive from the read loop with an absolute URI; a buffer opened as turbo-go main.go holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. pathKey makes both sides absolute — and the map of open documents is keyed the same way, so Knows does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.

refreshMarks runs on each turn of the loop, translating each file's diagnostics into editor.Severity marks. A line with several problems is marked with its worst: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.

Saving announces what the server does not know

A window that starts Untitled is skipped by every didOpen: it has no path to announce. The moment Save As gave it one used to send only didSave — and a server ignores didChange and didSave for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.

announceSaved is the fix, in afterSave so both save paths get it: a save of a path the server Knows reports the write, and a save of one it does not announces the document. And because Save As can also rename, save remembers the buffer's previous path before SaveAs rewrites it, and renamed — compared through pathKey, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# app

Assembles the editor: the screen, the desktop of windows, the menu bar, the status bar, the modal stack and the completion popup — and the routing that decides which of them sees each key and click.

Everything below this package is reusable on its own; this is where the decisions about *this* editor live.

## Event routing

One order, front to back, for both keys and clicks:

```
completion popup  →  modal dialog  →  open menu  →  focused terminal  →  menu bar  →  global shortcuts  →  desktop
```

`keyLayers()` is that list, in that order; each layer returns whether it consumed the event. A modal dialog consumes **everything**, including keys none of its controls wants, so nothing behind it can be typed into or dragged while a question is waiting.

Two layers are exceptions, and both are deliberate:

- The **completion popup** consumes the arrows, `Enter`, `Tab` and `Escape`, but lets printable characters through — which is what lets the user keep typing while the list narrows itself.
- A **focused terminal sits above the global shortcuts**, the one place the order is inverted. A shell wants `Ctrl-C`, `Ctrl-W` and `Ctrl-F`, and the editor would otherwise take all three. `terminalTakesKey` gives it everything except what `editorOwnedKey` reserves — the function keys, `Alt-X`, and `Alt-0``Alt-9` — which are the only way out of a full-screen program.

## Dialogs are asynchronous

There is no nested event loop. `pushModal(dialog, onClose)` puts a dialog in front, and `settleModals` — which runs after every event — pops the ones that have been answered and calls their callbacks. A dialog that opens another one therefore just works, with no re-entrancy anywhere.

In the **Open / Save As** box, the Name field and the list are kept in step: `ListBox.OnSelect` writes the highlighted entry into the field, so the field always says what **OK** is about to act on, and `confirm` falls back to the highlight when the field is empty. Without that wiring the two controls are independent, and OK on a freshly opened dialog does nothing at all — the user has highlighted a file, the field is still empty, and there is no path to act on.

## Terminal windows are windows

`terminal` provides the widget; this package decides where a shell starts, what closes it, and which windows are terminals at all.

`a.terminals` maps a window to its view, and is the answer to "is this a terminal?" everywhere it matters: `editorViewOf` returns no editor for one, `closeWindow` short-circuits to `closeTerminal` rather than asking about unsaved work, and `Quit` calls `closeTerminals` so no shell outlives the editor.

A new terminal starts in the directory of the file in front — where `go build` and `git` want to be — and its title is refreshed on every turn of the event loop, because a program inside it can rename itself from a goroutine nothing else watches.

## Agent windows are windows too

`acp` provides the protocol client and the widget; this package decides which agents exist, starts one per window, and answers the questions an agent asks of its client.

`a.agents` maps a window to its session, and is the answer to "is this an agent?" everywhere it matters — exactly as `a.terminals` is for a shell. Closing one asks nothing: a conversation is a running process, not unsaved work.

Two things are this package's rather than `acp`'s, and both are about the editor around the conversation:

- **The permission dialog.** `session/request_permission` arrives on the session's reading goroutine; `recordPermission` puts it in a queue and wakes the loop, and `askNextPermission` opens the box on the next turn. One at a time — `pendingPermissions.asking` holds the rest back — because two agents asking at once would stack two modals over each other. Escape means 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.
- **The file methods.** `readForAgent` answers from the **buffer** when the file is open and modified, so the agent sees the edit you just made rather than the version on disk. `writeForAgent` puts the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable with `Ctrl-Z` and yours to keep with `F2`. Both take `agentFiles`, because they run on a session's goroutine while the event loop may be writing the same buffers.

## Project settings and automatic saving

A project's settings file is read by `main` and handed here through `UseSettings`, which applies autosave and remembers the path. The theme is **not** applied here: `main` resolves it first, because a `-theme` flag outranks the project's choice and only `main` knows whether one was given.

**Saving the settings file re-applies it.** `UseSettings` used to be the only call, made once from `main`, so editing `settings.toml` in the editor did nothing until the next start-up — which is what a user reported. `reapplySettings` now runs after every successful write and, when the path written *is* this project's settings file, re-reads it and applies autosave. It matches on the path rather than on `a.settingsPath`, because a project that had no settings file has nothing remembered and creating one has to count. A file that no longer parses says `Saved, but not applied: …` and the previous values stay: that is a third outcome, distinct from "saved" and from "cannot save", and the only one that leaves the editor behaving unlike the file on the screen.

That step lives in `afterSave`, which is the tail both save paths share. The File menu's save and automatic saving differ only in how they report a failure, and a step added to one used to be missing from the other — this one would have been.

The theme is deliberately *not* re-applied on save: **Options ▸ Theme** is the live path for it and already writes the choice back here, and a `-theme` flag is the more explicit statement for the session it was given in.

`a.settingsPath` is the whole of the write-back rule. It is empty in a project with no settings file, and picking a theme then changes nothing on disk — a theme tried for ten seconds must not put a directory into someone's repository. Once the file exists, `rememberTheme` rewrites one key of it in place.

**Autosave is state, nudged by a timer — not driven by one.** `noteEdit` sets a deadline; `saveDueDocuments` checks it at the top of every turn of the event loop, exactly as `announceOpenDocuments` does. A `time.AfterFunc` calls `wake` so the loop turns at all while nothing is happening, but that wake-up is a `PostEvent`, and `PostEvent` drops what does not fit. A dropped nudge costs a late save; a design where the timer did the saving would lose it.

Two consequences worth knowing: a failed save clears the deadline **before** writing, so a read-only file is tried once per edit rather than forever; and it reports on the status bar, never in a dialog, because a modal reappearing every two seconds is worse than the problem it describes.

## The project tree is one window, held by hand

`filetree` provides the widget; this package decides where it is rooted, that there is only one, and when it is re-read.

`a.treeWindow` and `a.treeView` are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. `ProjectTree` raises the existing window instead of opening another, the same way opening an already-open file raises its window.

The tree is rooted at `os.Getwd()` — the same rule the settings file follows, so "the project" means one thing throughout the editor. `refreshTree` runs after every save, which is the one moment the editor knows the project changed; everything else is the user's `F5`.

## The Go menu, and putting files back afterwards

`tools` holds the commands; this package runs one in a terminal window and deals with what it did to the files.

**A command may ask for values first.** A `{{label}}` in it opens a box titled after the tool, one field per label, before anything runs; Escape runs nothing. What was typed is remembered for the session, per tool, and never written to disk. `MaxParameterFields` says how many fit on a screen, and a tool asking for more is refused with a message rather than given a box whose OK button is off the bottom.

**Where the output goes is the tool's choice**, from the project's `tools.toml`. A **popup** is the default: it opens at once and fills in as the command runs, its title carrying `— running` and then `— ok` or `— exit n`, and closing it stops a command still going. A **terminal** window is what an interactive or long command wants — `Run` is the one default that asks for it — and an **editor** window is for output to work through with `Ctrl-F`.

The popup is refreshed from `tick`, not from the reading goroutine, which may not touch a dialog.

**`Format` rewrites files on disk, including the one in front.** Without anything further the editor would sit on a stale copy and the next `F2` would write the unformatted version back over gofmt's work — the feature quietly undoing itself. So `reloadAfterTools` re-reads every open file with no unsaved changes, at the top of the event loop, and says on the status bar how many. A file **with** unsaved changes is left alone and counted: the edit and the formatter genuinely disagree, and the editor is not in a position to decide.

The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work.

`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven**`announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing.

## Menus the tools file asks for

A tool's `menu` key is a free-form name, so the **set** of menus on the bar comes from a file that changes while the editor runs. `Menu.OnOpen` cannot cover that — it refills one menu's items, and a menu that does not exist has no `OnOpen` to call — so `allMenus` builds the whole bar and `refreshToolMenus` replaces it through `ui.MenuBar.SetMenus`.

Rebuilding is guarded by the tools file's **size and modification time**, kept in `a.toolsStamp`. Parsing the file on every turn of the loop would work and would also be work done for nothing on every keystroke; one `stat` per turn is what it costs instead. The stamp is taken *before* the bar is built, so a file written between the two is picked up on the next turn rather than missed.

**Hot keys for those menus are assigned here, not read from the file**, because only the bar knows which letters are taken. `hotKeyLabel` marks the first free letter of the name and reserves it; tildes written into the name are kept when the letter is free and dropped when it is not. That asymmetry is the point: the bar answers the *first* menu matching a key, so a clash makes one menu silently unreachable — the bug `Snippets` and `Search` already produced once, with every test passing. A name whose every letter is taken gets no hot key at all, which `F10` and the mouse still reach.

`Go` stays a fixed menu rather than becoming another name from the file: it holds **Create tools file**, which has to be reachable in a project that has no tools file.

`createProjectFile` is the one place the three project files are created from — settings, snippets and tools all do the same dance, and it is written once.

## The About box says only what the build recorded

`app.Version` is gone. The number comes from `version`, which takes it from the linker's stamp, then from the Go build system, then gives up and says `unknown` — because a constant in the source is accurate the day it is written and wrong for every commit after it, and an About box is exactly where a number nobody set gets believed.

`aboutText(name, language, info, themeName)` is a pure function, so what the box says is tested without opening one. It **omits a line whose fact is empty** rather than showing a blank one: a binary from `go install …@v0.2.0` knows its version and nothing else, and `Commit:` with nothing after it says only that the editor failed to fill it in.

The language comes from the profile rather than from this package's own prose. It did not, and the sentence "A Turbo C-style editor for Go" was drawn by Turbo Rust — the shape of mistake this whole package is meant to make impossible, in the one string nobody thought of as a setting.

## The language server is optional by construction

`Language` wraps the whole gopls conversation. When there is no server — not installed, or failed to start — every method is a **no-op**, not an error, so the rest of the app never has to ask whether a language server exists. Its state shows on the status bar; editing and colouring carry on regardless.

Diagnostics arrive on the connection's read loop, off the main goroutine, so `Language` posts an interrupt event to wake the event loop rather than drawing from there.

## What is here

| File | |
| --- | --- |
| `app.go` | The `App`: layout, drawing, routing, the modal stack |
| `actions_file.go` | New, open, save, close, quit, and where a new window goes |
| `actions_edit.go` | Undo, redo, clipboard, select all |
| `actions_view.go` | Tile, cascade, maximise, themes, line numbers, help, About |
| `terminals.go` | Opening, closing and tracking terminal windows |
| `agents.go` | Opening, closing and tracking agent windows; the file methods; the status report |
| `agent_permissions.go` | The queue of agents waiting for an answer, and the dialog that gives one |
| `agent_files.go` | The project's files, as the `@` picker in an agent window lists them |
| `tree.go` | The project tree window: opening it, closing it, refreshing it |
| `snippets.go` | The Snippets menu, and inserting one |
| `gotools.go` | The Go menu: running a command in a terminal, and re-reading what it rewrote |
| `toolmenus.go` | The menus the tools file asks for, and the hot keys they get |
| `autosave.go` | The idle deadline and the saving it triggers |
| `project.go` | The project settings file: creating, opening, writing the theme back |
| `menus.go` | The menu bar and status bar, in one readable place |
| `dialogs.go` | File browser, message, confirm, prompt, find, choice |
| `complete.go` | Asking for a completion and accepting one |
| `completion.go` | `CompletionBox`, the popup itself |
| `language.go` | `Language`, the optional gopls conversation |

## Menus

**File** New · Open · Save · Save as · Close · Exit
**Edit** Undo · Redo · Cut · Copy · Paste · Select all
**Search** Find · Find next · Find previous · Go to line · Go to definition
**Run** Completion · Describe symbol · Language server status
**Options** Theme · Line numbers · Create project settings · Project settings
**Window** Next · New terminal · Project tree · Tile · Cascade · Maximise · List
**Snippets** built from the project's snippets file · Create snippets file
**Agent** built from the project's agents file · Cancel turn · Create agents file · Agent status
**Go** built from the project's tools file · Create tools file
*Any menu the tools file names*, between Go and Help, built from the tools that named it
**Help** Keyboard · About

Items that need a file open say so through `Enabled`, so the menu greys them out instead of doing nothing when chosen.

## Tests

The whole application is exercised through `tcell.SimulationScreen` — layout, routing, dialogs, drawing — with only the terminal itself replaced.

The terminal-window tests pin `$SHELL` to `/bin/sh` and skip themselves where there is no `/dev/ptmx`, so they neither depend on the developer's login shell nor fail on a platform that has no pseudo-terminals.

```sh
make test
go test ./app/
```

## The project's three files behave alike

`settings.toml`, `snippets.toml` and `tools.toml` all answer the same three questions — has the project got one, create it, open it — so the behaviour is written once and the differences are a `projectFile` value each: a title, how the file is named to a person, where the item that writes it lives, and two functions for existence and path.

The rule the menus enforce is that **exactly one of each pair is ever available**: you can create the file you have not got, and open the one you have. `HasProjectTools` and its two siblings decide it, and `not` inverts one for the create item. Before this, choosing *create* twice opened the file instead — defensible, and not what anybody expected from an item that says "create".

Both branches stay reachable from the API, because a caller that is not a menu has no greying-out: creating over an existing file still reports `Already there`, and opening a file that is not there still says where the item that makes it lives.

## Everything asked of the language server is in one menu

Describe symbol lived in Run and Go to definition in Search, and neither is where a reader looks for "what else knows about this name". They are in **Code** now, with the four questions that answer with places and the two that answer with symbols. Their keys did not move.

Every item acts on the **cursor**, not a selection. Almost every request in the protocol takes a position rather than a range, so requiring a selection first would be an invented step that answers nothing extra.

`showLocations` is where the four location questions end up, and it has four outcomes rather than two. **A server that is not ready says so**, because "nothing found" and "I cannot answer yet" look identical to a user and only one of them is worth waiting out — it is the most confusing way completion fails, and this would have inherited it for free. **One answer jumps, several offer the list**: `GoToDefinition` used to take `locations[0]` and throw the rest away, so an interface with four implementations sent you to one of them, chosen by the server's ordering.

A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.

## Diagnostics are keyed by absolute path

They arrive from the read loop with an absolute URI; a buffer opened as `turbo-go main.go` holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. `pathKey` makes both sides absolute — and the map of open documents is keyed the same way, so `Knows` does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.

`refreshMarks` runs on each turn of the loop, translating each file's diagnostics into `editor.Severity` marks. A line with several problems is marked with its **worst**: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.

## Saving announces what the server does not know

A window that starts Untitled is skipped by every `didOpen`: it has no path to announce. The moment Save As gave it one used to send only `didSave` — and a server ignores `didChange` and `didSave` for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.

`announceSaved` is the fix, in `afterSave` so both save paths get it: a save of a path the server `Knows` reports the write, and a save of one it does not announces the document. And because Save As can also **rename**, `save` remembers the buffer's previous path before `SaveAs` rewrites it, and `renamed` — compared through `pathKey`, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.