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