# Neoswarm (neosh): full documentation > A terminal workspace for running coding agents, built the way Neovim is > built: one core in Rust, any model, and every feature shipped as a > TypeScript plugin on a public API. This file is generated at build time > from the site's docs, the repository's canonical guides, and the plugin > API source. Repository: https://github.com/neoswarm/neosh Contents: 1. Website docs (16 pages) 2. Canonical repository guides: docs/config.md, docs/plugins.md, docs/testing.md 3. The @neosh/api TypeScript surface: index.ts and ui.ts, verbatim ==================== # Start Guide URL: https://neoswarm.dev/docs ==================== ## Install One command builds neosh from source and puts it on your path: ```sh curl -fsSL https://neoswarm.dev/install.sh | sh ``` Homebrew, Cargo and a plain source build are covered in [Installation](/docs/installation). ## Start the workspace ```sh neosh ``` That starts a workspace and attaches your terminal to it. No API key is needed: an existing `claude` login works as is, and hundreds of models are reachable before you configure anything. Three things worth knowing on day one: 1. The workspace is a process, and your terminal is a view of it. Closing the terminal detaches, turns keep running, and running `neosh` again puts you back. 2. `neosh stop` is what actually ends a workspace. `^Q` never can. 3. `neosh init` writes a starter config you can edit later. You do not need it to begin. ## Your first conversation 1. Type a message and press `⏎`. That starts a turn. 2. While the turn runs, `⏎` steers it: your message is held and taken in at the agent's next gap. 3. `Esc` interrupts. The agent is asked to stop, so the conversation survives it. 4. `^P` picks a different model, even mid turn. 5. `^N` starts a new conversation. In a repository it asks where: right here, a fresh worktree, an existing one, another machine, or somewhere else. A worktree you did not name gets its branch named from your first message, so `fix/composer-paste-truncation` appears instead of a random placeholder. ## Find your way around | Key | Opens | | --- | --- | | `^T` | Projects and conversations | | `^K` | The command palette | | `^S` | The transcript in normal mode, for moving and copying with Vim motions | | `^Z` | Every key binding, read from the live registry | ## Next steps - [Concepts](/docs/concepts) explains the workspace model in five minutes. - [Keys](/docs/keys) is every binding that ships — all of them defaults you can move. - [Scripting](/docs/scripting) is `neosh agent`: running many conversations at once, from a shell or another coding agent. - [Configuration](/docs/configuration) covers `init.ts` and `config.toml`. - [Plugins](/docs/plugins) shows how to write and install one. ==================== # Installation URL: https://neoswarm.dev/docs/installation ==================== ## Requirements - macOS or Linux. - `git`. - The Rust toolchain, for every method except Homebrew. Get it from [rustup.rs](https://rustup.rs). ## Install script The recommended route. It checks for `git` and `cargo`, builds a release binary from the latest source, and installs it to `~/.local/bin`: ```sh curl -fsSL https://neoswarm.dev/install.sh | sh ``` Set `NEOSH_INSTALL_DIR` to install somewhere else: ```sh curl -fsSL https://neoswarm.dev/install.sh | NEOSH_INSTALL_DIR=/usr/local/bin sh ``` The script never asks for sudo, never touches your config, and prints every path it writes to. Reading it first is one click: [install.sh](https://neoswarm.dev/install.sh). ## Homebrew ```sh brew install neoswarm/tap/neosh ``` ## Cargo ```sh cargo install --locked --git https://github.com/neoswarm/neosh neosh ``` ## From source For hacking on neosh itself: ```sh git clone https://github.com/neoswarm/neosh cd neosh cargo run ``` ## After installing Start it: ```sh neosh ``` No API key is needed. An existing `claude` login works as is, and hundreds of models are reachable before you configure anything. Two commands worth knowing: ```sh neosh init # writes a starter config and the API types neosh paths # where config, data and state live on this machine ``` ## Updating Type `/update` in the chat. It checks the releases page, updates by whichever route this install takes — a Homebrew install runs `brew update && brew upgrade neosh`, an npm one `npm install -g neosh@latest`, a cargo one `cargo install neosh --force`, and a binary from the install script is downloaded, checked against its published sha256 and swapped in — with the tool's own output on a progress row while it runs, and then restarts the workspace into the new binary once nothing is running. The sidebar says when a newer version is out, and says when a workspace is still running an old binary that has already been replaced under it — by `/update` or by an upgrade you ran in a shell. Doing it by hand still works: re-run the install script, or `brew update && brew upgrade neosh`, or re-run the `cargo install` command. (The `brew update` is not optional: Homebrew reads formulae from a clone of the tap on your disk and only refreshes it once a day, so a bare `brew upgrade` spends the first day of a release insisting the version you have is the newest one.) Each replaces the binary in place. A workspace that is already running keeps executing the old binary until you stop it; the terminal tells you when the two have drifted, and `/update` finishes the job by restarting into it. ## Uninstalling Remove the binary, then the directories `neosh paths` lists if you want a clean slate: ```sh rm ~/.local/bin/neosh rm -rf ~/.config/neosh ~/.local/share/neosh ~/.local/state/neosh ``` ==================== # Concepts URL: https://neoswarm.dev/docs/concepts ==================== ## The workspace is a process `neosh` attaches your terminal to the workspace for your config directory, starting one if there is none. Closing a terminal detaches; every turn keeps running. `neosh stop` is what ends a workspace. Several terminals can attach to the same workspace at once. Each has a view of its own, with its own conversation on screen, its own scroll and its own panels, while the conversations and the turns running in them are shared. Leave mid answer, come back later, and the answer is still arriving. ## Conversations and turns A conversation belongs to a directory, and that directory is where its work happens: the files the agent reads, the commands it runs, the repository git answers about. - `⏎` sends a message. While a turn is running it steers instead: the message is taken in at the agent's next gap. - `Esc` interrupts the turn. The agent is asked to stop, so the conversation survives it. - A turn that finishes while you are elsewhere marks its conversation unread. Arriving clears the mark; there is nothing to dismiss. - Switching conversations is never refused. Turns keep running where they are. ### The transcript keeps time Every turn carries the time in the right margin of the blank row above its question: ``` 8 Sep 14:32 · 4m 12s ▌ why is the composer eating pasted newlines Looking at the paste path… ``` When it was asked, and — once it has ended — how long it took. The newest turn also says how long ago it finished (`· 12m ago`), which is what you want when you walk back to a terminal that has been sitting there; it is kept true while it is the newest, and settles the moment you ask something below it. Every turn above says only things that are true forever, because those rows are written once and nothing comes back to them. The date leads the margin only when the day changed since the turn above — a whole afternoon's work is one date and a column of clock times — and the year only when that changed too. `chat.clock = "12h"` writes `2:32pm` instead. It is drawn beside the transcript rather than into it, so `y`, `ym` and `ya` copy what was said and never what neosh wrote in the margin about it. `chat.times = false` turns it off. Durations read the same wherever the workspace prints one: seconds under a minute, `4m 12s` under an hour, `2h 30m` past that — on the working line while a turn runs, on a tool card, on a conversation's row in the sidebar, and here. ## Projects and worktrees The sidebar (`^T`) groups conversations by project. A worktree nests under the repository it belongs to, named by its branch, so four scratch trees of one repository are one project, not four. `^N` starts a conversation and asks where: here, a fresh worktree, an existing one, another machine, or elsewhere. A worktree you did not name gets its branch named from your first message. ## Permissions and questions Each conversation owns its permission mode: full access, ask, allow listed, or deny. `⇧⇥` cycles it, the choice is saved with the conversation, and it takes effect on the turn that is running. Anything irreversible asks first, and nothing reversible does. When an agent has a question for you, it appears as a panel over the composer: pick an option, or just type, since typing is answering. ## Everything is a plugin Every feature ships on the same public API a third party plugin uses. The chat, the sidebar, git, the palette: all plugins, all defaults, and a default is something you turn off. That means the workspace is yours to reshape. Disable any bundled piece with `plugins.disabled`, replace it with a plugin of your own, or rebind any key from `init.ts`. [Plugins](/docs/plugins) shows how. ==================== # Scripting URL: https://neoswarm.dev/docs/scripting ==================== `neosh "why is the composer eating my paste"` starts a conversation in this directory, sends that, and draws the answer arriving. It is the terminal, with the first question already asked. Quote it when the first word is also a subcommand name: `neosh status of the build` runs `neosh status`. `neosh agent` is the headless half. It sends the **same `ApiCall` a plugin sends**, over the **same socket a terminal attaches to** — so what a script can do is exactly what a plugin can do, and a conversation started this way is an ordinary one that `^T` lists and you can take over. A control connection is not a terminal. It does not attach, it does not take an attached one over, and `neosh status` does not count it as somebody watching. ## The verbs | Command | Does | | --- | --- | | `neosh agent start [PROMPT…]` | Start a conversation and say something in it. Prints its id | | `neosh agent ls` | Every conversation: id, state, project, label, directory | | `neosh agent send TEXT…` | Say something. While a turn runs this steers it | | `neosh agent read ` | The transcript | | `neosh agent watch [id]` | Follow, live | | `neosh agent wait ` | Block until the turn ends | | `neosh agent interrupt ` | Ask the turn to stop. The conversation survives it | | `neosh agent rename [TITLE…]` | Name it, or take the name off | | `neosh agent archive ` | Put it away. `--restore` brings it back | | `neosh agent rm ` | Delete it and its file. Irreversible, so it asks | | `neosh agent models` | Every model this workspace can reach | | `neosh agent model inst/model` | Change which model a conversation uses | | `neosh agent commands` | Every command registered — neosh's own and every plugin's | | `neosh agent run [args…]` | Run one of them | | `neosh agent call ''` | One raw `ApiCall`, for anything with no verb yet | Ids are uuids; every listing prints the first eight characters and every verb takes them back. `.` means whatever your terminal is looking at. An ambiguous prefix is refused with the candidates rather than guessed at. Verbs that only *ask* — `ls`, `read`, `watch`, `wait`, `models`, `commands` — will not start a workspace. They say there is none and exit 1. ## Asking one question ```bash neosh agent start --wait --timeout 600 "summarise what changed on this branch and why" ``` `--wait` blocks until the turn ends and prints what the agent said. The exit status is the answer: `0` finished, `1` failed or refused, `2` interrupted, `124` the timeout ran out and the turn is **still running**. So `neosh agent start --wait "…" && ./deploy.sh` means what it looks like. ## Running many at once Nothing here starts a second copy of neosh — they are all sockets onto one workspace, which is what makes twenty of them cheap. ```bash : > /tmp/ids for c in crates/*/; do neosh agent start -C "$c" --title "audit $(basename "$c")" \ "Audit this crate for unwrap() on plugin input. Report findings only." >> /tmp/ids done while read -r id; do neosh agent wait "$id" --timeout 900; done < /tmp/ids while read -r id; do neosh agent read "$id" --text; done < /tmp/ids ``` Start everything before waiting on anything, or you have written a queue. `wait` on a conversation with nothing running returns `0` immediately. Several agents editing one checkout will fight. A git worktree each gives every one its own files, branch and index — and the sidebar nests them under the repository they belong to: ```bash git worktree add ../wt-login -b fix/login neosh agent start -C ../wt-login "fix the login redirect loop" ``` ## Parsing the output Pass `--json` for anything you parse. The plain output is laid out for a person and is allowed to change; `--json` is the wire type verbatim, one object per line — `SessionInfo` for `ls`, `Message` for `read`, `PluginEvent` for `watch`. ```bash neosh agent ls --json | jq -r 'select(.active_turn != null) | .id' neosh agent read "$id" --json | jq -r 'select(.role=="assistant") | .content[] | select(.type=="text") | .text' ``` ## When nothing is happening Check whether `active_turn` is set. Set, with nothing arriving on `watch`, usually means the agent is **waiting on a person**: an agent can ask you a question, and a question is answered at a terminal. `neosh agent interrupt ` asks the turn to stop; the transcript survives, so `send` can redirect it. ## Anything with no verb ```bash neosh agent commands # every command, plugins included neosh agent run git.pull # run one neosh agent run git.clone https://github.com/owner/repo ~/src/repo # say where: see below neosh agent call '{"call":"session_list","include_archived":true}' ``` A verb that would otherwise open a picker takes the answer as an argument, because a picker is a thing only a person at a terminal can answer and a call that opens one with no screen behind it never returns. `git.clone ` asks where the repository goes; `git.clone ` writes it there and answers with the path. `call` takes a JSON-encoded `ApiCall` — the variants are the ones in `@neosh/api` — and prints the `ApiOk` it got back. `-` reads it from stdin. This is the whole plugin API with no verb in front of it. ## The skill, for coding agents If you drive neosh from Claude Code, Codex, Cursor or Gemini, install the skill rather than pasting this page: ```bash neosh skill install # for you, every agent it knows about neosh skill install --project # checked into this repository instead neosh skill install --agent claude # just one neosh skill where # say where it would go neosh skill show # print it, for somewhere this does not know about ``` It writes `SKILL.md` into `.claude/skills/neosh/`, `.agents/skills/neosh/` (Codex and anything else reading the shared Agent Skills location), `.cursor/skills/neosh/` and `.gemini/skills/neosh/`. Skills are read when a session starts, so restart your agent afterwards. Claude Code can also take it as a plugin: ``` /plugin marketplace add neoswarm/neosh /plugin install neosh@neosh ``` ## Security The socket lives in your own runtime directory at `0600`, and a Unix socket does not leave the machine — so reaching it means already being the person at the keyboard. Control calls are therefore not gated: a permission layer between somebody and their own workspace would be ceremony rather than security. The permissions that matter are unchanged. A tool call made by a conversation started from a script is asked about exactly as one started by typing, under that conversation's permission mode. Steering agents on *other* machines is a different question with a different answer — see [machines](/docs/machines). ==================== # Configuration URL: https://neoswarm.dev/docs/configuration ==================== ## Getting a config ```sh neosh init ``` That writes a starter config, a `tsconfig.json` and the API types, then tells you the path. Open `init.ts` and start editing. ## The one thing to understand Your config is a plugin. `init.ts` gets the entire public API, the same one every plugin uses, with nothing held back. There is no config-only API, no list of "supported config options" to memorize, and nothing a plugin can do that your config cannot. If `init.ts` throws, neosh still starts. The error is reported and startup continues, because you need the editor to fix the editor. ## Where things live The same roots Neovim uses, so symlinking the config directory into a dotfiles repo works exactly as you would expect: ``` ~/.config/neosh/ $XDG_CONFIG_HOME, or $NEOSH_CONFIG_DIR ├── init.ts your config, the important file ├── config.toml plain values, the non-code path ├── tsconfig.json generated, so your editor knows the API ├── types/ generated API types for this exact binary └── plugins// drop a plugin here and it loads ~/.local/share/neosh/ installed plugins, managed by neosh ~/.local/state/neosh/ sessions, plugin state, trust.json /.neosh/ per-project config ├── config.toml └── init.ts ``` ```sh neosh paths # what those resolve to here, and which exist ``` All overridable with `NEOSH_CONFIG_DIR`, `NEOSH_DATA_DIR`, `NEOSH_STATE_DIR`, `NEOSH_CACHE_DIR`, or `--config-dir`. `neosh --clean` reads and writes nothing at all, which is the right mode for reporting a bug: it answers "is this neosh or is it my setup". ## init.ts ```ts import type { PluginContext } from "@neosh/api"; export async function activate({ neosh }: PluginContext) { await neosh.opt.set("agent.model", "claude-cli/sonnet"); await neosh.cmd.register("chat.clear", async () => { /* … */ }, { desc: "Clear the conversation", }); await neosh.keymap.set("chat", "", "chat.clear"); } ``` Keys bind to command names, never to callbacks. That indirection is what makes every binding listable with `^Z`, remappable, and callable by other plugins. [Keymaps](/docs/keymaps) covers the details. Your config runs before plugin discovery, which is what lets it decide what else loads: ```ts await neosh.rtp.add("~/src/my-plugins"); // another plugin directory ``` ## config.toml For setting values without writing code. It expresses a subset of what `init.ts` can; it exists so "use this model" is one line, and so a settings UI has a file it can rewrite. ```toml [agent] model = "anthropic/claude-opus-5" [permissions] mode = "ask" # ask | allow_listed | deny allow_commands = ["cargo"] [options] "chat.show_thinking" = true "sidebar.width" = 34 [[providers]] id = "local" driver = "openai-compat" display_name = "llama.cpp" base_url = "http://localhost:8080/v1" auth = { kind = "env", var = "MY_API_KEY" } plugin_dirs = ["~/src/neosh-plugins"] ``` Unknown keys are an error, not ignored. A typo that is silently skipped would look exactly like a setting that does not work. `auth` names where a key is, never a key: `{ kind = "env", var = "…" }`, `{ kind = "command", argv = [...] }`, `{ kind = "cli", program = "codex", login = "codex login" }` for a plan behind a vendor CLI, or `{ kind = "none" }` for a local endpoint. Nothing writes a secret to this file, and nothing reads one from it. Every key under `[options]` is documented in [Options](/docs/options). ## Per project `/.neosh/config.toml` is committable and applies to anyone who opens the repository. Because a config file in a repository you cloned is code someone else wrote, it is split by what it can do: - **Applies immediately**: `model`, since it can only name a provider you already configured, and `[permissions]`, but only to make things stricter. A repository cannot grant itself `curl`. - **Waits for `neosh trust`**: `.neosh/init.ts`, `plugin_dirs`, `providers`, `[options]`, `system_prompt`. Anything that runs code or widens what the agent may do. ```sh neosh trust # prints the files, then approves them neosh trust --list neosh trust --revoke neosh init --project # scaffolds .neosh/ in the current directory ``` Trust is keyed on the contents of every file under `.neosh/`, not on the path. Editing any of them, including a module `init.ts` imports, revokes it automatically, so a `git pull` cannot quietly change what runs on your machine. You are told, and you re-approve after reading the change. ## Precedence Later wins: 1. Built-in defaults 2. `~/.config/neosh/config.toml` 3. `/.neosh/config.toml` 4. `~/.config/neosh/init.ts` 5. `/.neosh/init.ts`, if trusted 6. Command-line flags Flags last, so `--model` always takes effect. ## Reloading `^R` reloads without a restart: 1. Every layer is re-read from disk. 2. Every plugin is unloaded, and settings reset to their defaults first, so deleting a line actually removes its effect. 3. If the new config does not parse, the running one is left alone and you are told why. Two things reload does not do yet: close what a plugin opened during `activate` (use `ctx.subscriptions` for anything that should not outlive your plugin), and interrupt an in-flight turn, which keeps streaming. ## Type checking The types in `types/` were written by the binary you are running and are refreshed at startup if they drift, so they always describe the version you actually have: ```sh cd ~/.config/neosh && npx tsc --noEmit ``` Plugins are transpiled, not type-checked, at load. Running `tsc` yourself is the only thing that catches a type error before it becomes a runtime one. ==================== # Options URL: https://neoswarm.dev/docs/options ==================== ## How options work Set them in `config.toml` under `[options]`, or from code: ```ts await neosh.opt.set("chat.show_thinking", true); const on = await neosh.opt.get("chat.show_thinking"); await neosh.opt.all(); // everything declared, with types and defaults neosh.opt.onChange((e) => { /* fires for every option, not just yours */ }); ``` Plugins declare their own options through the same call the built-ins use, and a declared option is settable from `config.toml` like any other, including by a user who set it before the plugin loaded. `opt.all()` is always the complete, current list; the tables below are the ones that ship. ## Agent | Option | Default | Effect | | --- | --- | --- | | `agent.model` | `""` | `instance/model`. Empty picks the first that works | | `agent.system_prompt` | `""` | Replaces the built-in prompt when set | | `gen.model` | `""` | Model for branch names, commit messages and titles. Empty uses the conversation's model. Separate on purpose: naming a branch does not need a frontier model | ## Chat | Option | Default | Effect | | --- | --- | --- | | `chat.show_thinking` | `false` | Stream reasoning into the chat buffer | | `chat.show_tools` | `true` | Show a line when a tool runs. Errors always show | | `chat.tool_output_lines` | `3` | How much of a tool result to show under it. `0` gives the line count and nothing else | | `chat.markdown` | `true` | Render answers as they arrive. `false` shows exactly what the model sent | | `chat.preview_lines` | | How many rows a tool card opens to while the cursor is in it | | `chat.times` | `true` | The time in the transcript's right margin: when each turn was asked, how long it took, and how long ago the newest one finished | | `chat.clock` | `"24h"` | Or `"12h"`. Which clock those times are written on | ## UI | Option | Default | Effect | | --- | --- | --- | | `ui.theme` | `"dark"` | Or `"light"`, or any theme a plugin contributed | | `ui.motion` | `true` | Text that moves while something is happening. Off removes the movement and keeps the colour | | `ui.hints` | `false` | A shortcut row under the composer. Off, the sidebar says the same keys | | `ui.confirm_destructive` | `true` | Ask before anything irreversible. Turning it off turns off every such dialog, everywhere | | `ui.ascii_only` | `false` | For terminals without a decent font | | `ui.images` | `true` | Draw pictures in the transcript on a terminal that can — kitty, Ghostty, WezTerm, Konsole, tmux with passthrough. Elsewhere a picture is its name | | `ui.nerd_font` | `false` | Brand glyphs for providers, where the font has them. Deliberately not auto-detected | ## Keys | Option | Default | Effect | | --- | --- | --- | | `mapleader` | `"\"` | Set it before you use `` in a binding | | `timeoutlen` | `500` | How long to wait for the rest of a key sequence, in milliseconds | Every picker, prompt and completion list reads the `ui.keys.*` family, in Neovim notation, with more than one key allowed per action: ```toml [options] "ui.keys.next" = " " "ui.keys.prev" = " " "ui.keys.page_down" = "" "ui.keys.page_up" = "" "ui.keys.first" = "" "ui.keys.last" = "" "ui.keys.accept" = "" "ui.keys.dismiss" = " " "ui.keys.complete" = " " "ui.keys.clear" = "" "ui.keys.delete_word" = "" "ui.keys.window_prefix" = "" "ui.keys.hint_delay" = 300 ``` A widget claims these window-scoped while it is open, so they outrank global bindings and are released the moment it closes. That is what makes `^N` move in a picker even though `^N` is otherwise "new conversation". The last two are the window prefix and how long you have to hold it before it lists what follows. `ui.keys.hint_delay = 0` shows the list at once; a bigger number keeps it out of the way of anyone who already knows the letters. `ui.keys.delete_word` moved off `` when the prefix took it — a key cannot both delete a word and wait to see whether a window verb follows, because the waiting is what makes every word-delete feel late. Set them to each other's values if you would rather have it the old way round. ## Sidebar | Option | Default | Effect | | --- | --- | --- | | `sidebar.open` | `true` | Show it at startup | | `sidebar.width` | `34` | Also what `>` `<` `=` in the panel adjust, so the key and the file say the same number | | `sidebar.hints` | `true` | The main keys, at the foot of the panel | | `sidebar.legend` | `true` | While you are in the panel, a card beside it listing every key for the row under the cursor. Appears after `ui.keys.hint_delay` | | `sidebar.refresh_ms` | `4000` | A workspace re-read per tick | ## The repository block | Option | Default | Effect | | --- | --- | --- | | `git.sidebar` | `true` | Mark each project row with what has drifted from the remote, what is dirty, and which pull request its branch is | | `git.fetch.interval` | `180` | Seconds between asking the remote what is new, so `↓3` means three waiting for you rather than three as of whenever this checkout last spoke to a server. `0` never asks on its own, and the block then says how old its numbers are. Only the repository of the conversation you are in, and only when its branch tracks one | | `git.pulls` | `true` | The pull request on a project or worktree row: its number, its state, and its checks when they are failing or still running. Asks `gh`, so it needs the GitHub CLI signed in | | `git.pulls.interval` | `180` | Seconds between asking the forge. A pull request moves on somebody else's schedule, so unlike the working tree there is nothing local to notice it. `0` asks only when you press the key | ## Worktrees | Option | Default | Effect | | --- | --- | --- | | `worktree.root` | `"~/.nsh"` | Where new worktrees go, as `//`. A relative value keeps them inside the repository as `//`, with the `.gitignore` entry written for you. `""` restores the sibling layout | | `clone.root` | `"~/.nsh/repos"` | Where `^O` clones a repository, as `//`. The first destination offered; folders you clone into through `Somewhere else…` are remembered and offered beside it. Separate from `worktree.root`, which spends `//` on one repository's branches | ## Git prompts Every generated name or message has a prompt behind it, and every prompt is a setting in two layers: `*.instructions` is appended to the built-in prompt, which is the common case, and `*.prompt` replaces it entirely. Instructions still apply on top of a replaced prompt, so a per-project rule layers onto your own. ```toml [options] "git.branch.prefix" = "feature/" "git.branch.instructions" = "Start with the Jira key when the message mentions one." "git.commit.instructions" = "Follow Conventional Commits." ``` ## Usage | Option | Default | Effect | | --- | --- | --- | | `usage.sidebar` | `true` | The plan strip at the foot of the sidebar | | `usage.warn_at` | `90` | Say something the first time a limit passes this. `0` to never | | `usage.days` | `30` | The span the `^L` panel opens on | | `usage.poll` | `true` | Ask the vendor while nothing is running. See [Models](/docs/models) | | `usage.show_tokens` | `true` | The running token total, beside the context meter | ## Archive | Option | Effect | | --- | --- | | `archive.sidebar` | A count row in the project panel. Off by default: nothing archived is on screen until you ask | | `archive.sort`, `archive.group` | The panel's ordering and grouping. `s` and `S` in it change the same settings | | `archive.width`, `archive.height` | The popup's size | | `archive.auto_days` | Archive what has been idle this long. Reversible and free, so it may happen on its own | | `archive.retention_days` | What counts as old. Only ever counts; `archive.sweep` is the same number with a person behind it | | `archive.remind` | Say so once a day when old conversations accumulate | Nothing in the archive deletes on a timer. ## Plugins | Option | Default | Effect | | --- | --- | --- | | `plugins.disabled` | `[]` | Bundled plugins not to load. Their keys, rows and hints go with them | ==================== # Keymaps URL: https://neoswarm.dev/docs/keymaps ==================== ## The basics ```ts await neosh.opt.set("mapleader", ","); // set it before you use it await neosh.keymap.set("chat", "pa", "project.open"); await neosh.keymap.set("chat", "pp", "sidebar.focus"); await neosh.keymap.del("chat", ""); // drop a default you do not want ``` Keys bind to command names, never to callbacks. That is what makes every binding listable with `^Z`, runnable by name from `^K`, and replaceable by another plugin or by you. `` is substituted when the binding is made, not when the key is pressed, the same as Neovim and for the same reason: a binding that silently moved when you changed `mapleader` halfway down your config would be impossible to reason about. Nothing ships bound to ``, so it is entirely yours. A sequence you start and do not finish is typed literally after `timeoutlen` milliseconds, so `,pa` does not cost you the comma key: ```toml [options] mapleader = "," timeoutlen = 500 ``` ## Notation Neovim's: ``, ``, ``, ``, ``, ``, ``, `x`, multi-key sequences like `gd`. Modes are `chat` (the composer and everything around it) and `normal` (reading the transcript). ## Scopes Resolution is window, then buffer, then buffer kind, then global, first match winning. Binding against a kind is how you put a key inside a panel you did not open: ```ts // `x` archives a conversation by default; this replaces it, in the sidebar only. await neosh.keymap.set("chat", "x", "acme.mine", { scope: { kind: "buf_kind", name: "neosh.sidebar" }, desc: "Do it my way", }); ``` The panel's own default loses to yours, because a default that overwrites a choice is not a default. The tier order for keys, commands and highlight groups everywhere is: bundled plugin, then installed plugin, then your `init.ts`, later always winning. The host's own surfaces all have kinds you can bind against: `neosh.sidebar`, `neosh.transcript`, `neosh.composer`, `neosh.question`, `neosh.picker`, `neosh.confirm`, `neosh.prompt`. ## Why the defaults are what they are A default binding is a key every terminal sends: Ctrl with a letter, `⇧⇥`, `⏎`, `⌫`, `Esc`. Three families are ruled out on purpose: - **Function keys.** On a Mac the top row is brightness and volume until a setting says otherwise, so `F1` is a key a new Mac cannot press. - **Option and Alt.** macOS treats Option as a compose key: `⌥p` is `π` unless the terminal was told otherwise. - **Ctrl with punctuation.** In a plain terminal `Ctrl+/` and `Ctrl+7` are the same byte. Arrows, `PgUp`, `Home` and `End` are bound wherever they mean something and are never the only way to do anything. If you have the ruled-out keys, use them; the dropped defaults are one line each: ```ts await neosh.keymap.set("chat", "", "help.keys"); await neosh.keymap.set("chat", "", "model.upgrade"); await neosh.keymap.set("chat", "", "model.downgrade"); await neosh.keymap.set("chat", "", "chat.image.drop"); ``` ## Enhanced keys On startup neosh asks the terminal for the Kitty keyboard protocol, supported by kitty, Ghostty, WezTerm, foot, rio, Alacritty, iTerm2 and Windows Terminal, and quietly does without on terminals that lack it. It buys three things: `⌘` bindings like `` arrive at all, Ctrl with punctuation becomes distinguishable, and `Esc` is unambiguous so nothing waits to find out whether a sequence is coming. `NEOSH_NO_ENHANCED_KEYS=1` turns the request off. An environment variable rather than an option, because the terminal has to be in the right mode before the first keystroke, which is before there is any config. ## Picker keys Every picker, prompt and completion list reads the `ui.keys.*` settings, listed in [Options](/docs/options). A widget claims them window-scoped while it is open, so they outrank your global bindings and are released with the window. ## Raw keys, for widget authors Keys bind to command names, so a widget that needs raw input claims what nothing else wanted: ```ts const win = await neosh.float.open(buf, { focusable: true, closeOnBlur: true }); await neosh.focus.push(win); const release = await neosh.keymap.capture(win, "my.key"); ``` While the window is focused, keys no binding claimed are sent to `my.key` with a `KeyContext`. Bindings still win, so a capture cannot take away the key that quits. The capture is released when the window closes or the plugin unloads. ## Seeing what is bound `^Z` lists every binding and `^K` every command, both read from the live registry, so a plugin loaded five minutes ago is in them without anything having been written down. Every picker's foot strip is written from the bindings themselves, so rebinding `ui.keys.*` changes what the strip says rather than making it a lie. ==================== # Appearance URL: https://neoswarm.dev/docs/appearance ==================== ## Theme and motion ```toml [options] "ui.theme" = "dark" # or "light", or a contributed theme "ui.motion" = true # text that moves while something is happening "ui.hints" = false # a shortcut row under the composer "ui.ascii_only" = false # for terminals without a decent font "ui.nerd_font" = false # brand glyphs for providers, where the font has them ``` Motion is reserved for one thing: something is happening and you cannot see it yet. While a turn is in flight the working line sweeps, because a still screen and a wedged process look identical. `Status.Streaming` sweeps, `Status.Pending` pulses. The frontend animates on its own clock, so it costs about 1% of a core, stays on over SSH, and stops the moment the row scrolls out of sight. `ui.motion = false` removes the movement and keeps the colour; it never makes text disappear. ## Overriding colours The theme is a set of semantic groups, like `Status.Working`, `Git.Added` and `Meter.Fill`, that plugins link to rather than choosing colours. Override any of them from `init.ts` and a theme switch leaves yours alone: ```ts await neosh.hl.define("Status.Working", { fg: "#ff00ff", bold: true }); ``` Highlight groups have owners: a group is yours until you reset it or your plugin unloads, and then it goes back to the theme's. `neosh.hl.list()` shows every group with its owner, and `neosh.hl.get("Normal")` reads a resolved colour rather than making you guess at one. ## Colouring one window Neovim's `winhighlight`: a window, or every window of a kind, reads group names through a map, and nothing else on screen changes. ```ts await neosh.hl.define("Acme.Panel", { bg: { kind: "rgb", r: 26, g: 27, b: 38 } }); await neosh.win.setHighlights({ kind: "neosh.sidebar" }, { Normal: "Acme.Panel" }); ``` ## Shipping a theme A theme is a contribution: listed beside `dark` and `light` on `ui.theme`, applied when chosen, and gone with its plugin. Groups it does not name come from `base`. ```ts await neosh.ext.contribute("ui.theme", "gruvbox", { base: "dark", groups: { Comment: { fg: { kind: "rgb", r: 146, g: 131, b: 116 } }, "Gruv.Extra": { link: "Normal" }, }, }); ``` ## How answers are drawn Answers render as markdown as they arrive: headings lose their hashes, fenced code is indented under its language, and bold, italic and link markers are removed rather than shown, since a terminal can draw bold and drawing the asterisks as well says the same thing twice. Rendering is incremental: settled blocks are drawn once and never touched again, and only the trailing partial block is redrawn per tick. Tables are laid out in columns with a rule under the labels; one too wide to line up becomes one block per row instead. There is no syntax highlighting inside fences on purpose: doing it properly is a grammar per language, and doing it with a regex colours the wrong words in exactly the code you are reading closely. A fence gets one colour and its language in the corner. `chat.markdown = false` shows exactly what the model sent, for when the question is "what did it actually say". ## The status strip ``` chat ask ⇧⇥ main ███░░░░░ 38% of 200k ↑12k ↓4k claude-opus-5 ^P High ^E ``` Left is what the conversation is spending: the permission mode, the branch, how full the context window is, tokens in and out. Right is what it is spending it on. When the strip is too narrow, whole segments are dropped, least important first; nothing is ever truncated, because half a token count is a wrong token count. Every entry carries the key that changes it, immediately after it. Plugins own the contents: ```ts await neosh.status.set("model", { text: "opus", keys: "^P", align: "right", priority: 10 }); ``` ## The hint row `ui.hints = true` puts a row of shortcuts under the composer. It is not a hard-coded list: every entry is registered by whoever owns the feature, so a disabled plugin takes its shortcut with it and an installed one adds its own. ```ts await neosh.hint.set("model", { keys: "^P", label: "model", priority: 10 }); ``` Entries sort by priority and are dropped from the end when the terminal is too narrow. Reading mode draws its own key row whatever the setting says, because those keys are live, different, and written down nowhere else. ==================== # Sidebar and projects URL: https://neoswarm.dev/docs/sidebar ==================== ## Projects The sidebar groups conversations by the directory they were opened in. There is no registry to maintain: opening a conversation somewhere else is how a second project appears. `^O` adds one by path, with completion as you type — or by repository address, which clones it: paste `https://github.com/owner/repo`, the `git@` spelling, or just `owner/repo`, and it asks where it should go, remembers the folders you pick, and leaves you in the new project. `f` pins one to the top, `J`/`K` reorder, and `r` renames a conversation. You do not have to know any of that. The keys sit beside what they act on — `^T` on the `PROJECTS` heading, `^O` on the `+ Add project` row — and pausing on a row inside the panel opens a **key card** beside it, level with the row, listing every key for that row: fold, pin, pull, remove the worktree, whatever the row is and whatever plugins have added to it. It reads the live keymap, so a key you moved in `init.ts` is the key it prints. It appears after `ui.keys.hint_delay` (300 ms), never takes the keyboard, and `?` is still the whole sheet. `sidebar.legend = false` turns it off. A conversation's directory is where its work happens, not just where it is filed: the repository git answers about, the root the agent's file tools resolve against, and the directory the model's own agent is started in. Switching conversations moves all of it. The arrangement (pins, order, folds) is saved under `~/.local/state/neosh/plugin-state/`, not in your config, because an editor that rewrites the file you hand-edited because you pressed a key is one you stop trusting with it. ```toml [options] "sidebar.open" = true # show it at startup "sidebar.width" = 34 # also what > < = in the panel adjust "sidebar.hints" = true # the main keys, at the foot of the panel "sidebar.legend" = true # the key card beside the row you are on "sidebar.refresh_ms" = 4000 ``` ## The repository block At the top of the column, above the projects: which branch the conversation you are in is on, what has drifted from the remote, and one key that does something about it. ``` GIT ^G ────────────────────────────────── main ↓3 ~1 ?1 ↓ pull 3 commits now ``` The second row is a button, and its label is rebuilt from the state every time it is drawn, so it is never pointed at something that would do nothing: | It says | Because | `↵` does | |---|---|---| | `↓ pull 3 commits` | three commits are waiting for you | fast-forwards, and says what git said | | `⇄ diverged — 2 up, 3 down` | both sides have moved, so a plain pull cannot just fast-forward | asks **rebase or merge**, in those words, and does the one you pick | | `↻ check for changes` | nothing is waiting as of the last time we asked | asks the remote again | | `→ no upstream` | this branch tracks nothing, so there is nothing to be behind | nothing — it is a statement, not a verb | `⇥` on a git row steps the block between `one` (the two rows above) and `full`, which adds the upstream it tracks and a line about the working tree. `^G` opens the full status, and every row in the block opens it on `↵`… except the button, which is the button. ### The pull request A branch tells you nothing about whether anybody has looked at it. So the block names the pull request for the branch you are on, and every project and worktree row carries its number: ``` GIT ^G ────────────────────────────────── fix/the-login ↑2 #91 open ✗ failing ↻ check for changes now ``` ``` ▾ neosh ▾ ⎇ fix/the-login #91 ✗ 2 ▾ ⎇ feature/panels #88 • 1 ▾ ⎇ chore/deps #84 3 ``` The number is coloured by state — open, draft, merged, closed — and a check mark appears **only when there is something to say**: `✗2` when checks are failing, one glyph that turns while they are running, and nothing at all when they passed. A tick beside every merged pull request from the last six months is a column of marks nobody reads. `↵` on the pull request row copies its URL. It does not open a browser: neosh has none, and on the machine a coding agent usually runs on there is not one to open — the clipboard reaches the laptop you are actually sitting at, which is why it is OSC 52. It asks `gh`, the CLI you have already signed into, so tokens, enterprise hosts and SSO are already handled and the answer is the one you get in your own terminal. One request per **repository**, not per row: six worktrees of one checkout are one call and six lookups. ```toml [options] "git.pulls" = true # the number on the rows "git.pulls.interval" = 180 # seconds; 0 asks only when you press the key ``` Without `gh`, signed out, or on a repository with no forge remote, the rows simply carry no pull request. It is said once in the log, not every few minutes — those are states somebody has to fix, not events. `^K git.pulls` re-asks and tells you out loud. ### The numbers are asked for, not remembered `↑` and `↓` come from `git status`, which compares your branch with the copy of the remote **sitting on your disk** — so drawn without ever fetching, `↓0` means "nothing had arrived as of whenever this checkout last spoke to a server", which after an afternoon's work is a sentence about breakfast. So neosh fetches: on a slow timer, and when you arrive in a conversation. ```toml [options] "git.sidebar" = true # the block itself "git.sidebar.style" = "one" # "one" or "full" — ⇥ steps it "git.fetch.interval" = 180 # seconds; 0 never asks on its own ``` Only the repository of the conversation you are in, and only when its branch tracks a remote one. The dim column at the right of the button is how old the numbers are — `now`, `12m`, or `⚠ offline` when the last ask failed. With `git.fetch.interval = 0` nothing leaves your machine unless you press the key, and that column is what tells you the numbers are old rather than untrue. While a fetch or a pull is out, the row spins and its text sweeps. Nothing else in the block moves: `↓3` is news, not something happening, and a row that blinks about news you have already read costs attention every time it changes. ## Worktrees A worktree is a second checkout of the same repository on a different branch, and neosh treats one as a project: it nests in the sidebar under the repository it is a tree of, named by its branch, with its own conversations, fold and order. `^N` asks where a new conversation goes when there is something to ask: ``` New conversation ❯ ● Here /home/you/work/project + In a new worktree a clean branch, named for you ⌂ In a new worktree, in this project kept in .worktrees/ + In a new worktree, named… a branch of its own ⎇ fix/thing worktree · ~/.nsh/project/fix-thing … Another directory… somewhere else entirely ``` `Here` is selected, so `^N ⏎` is what `^N` always did, and outside a repository the question is not asked at all. `n` in the project panel asks the same question about the project under the cursor. The second row is the one you want most days: a clean branch named for you (`brisk-otter-k3f9` — two words you can say out loud and a tag so no two trees ever collide), renamed automatically from your first message in it, so it becomes `fix/composer-paste-truncation` without you ever naming it. Where trees land is one setting: ```toml [options] "worktree.root" = "~/.nsh" # the default: // "worktree.root" = ".worktrees" # relative: kept inside the repository "worktree.root" = "" # sibling of the repository ``` With a relative root, neosh writes the directory into the repository's `.gitignore` for you, so an in-repo checkout never sits in `git status` as one giant untracked directory. On a worktree's sidebar row: `y` copies its path for the shell you are about to `cd` in, `p` pulls its repository from the remote, and `d` removes the checkout from disk. The branch stays, it asks first, and it tells you how many conversations go with it. `p` marks the row busy from the press rather than from the network call three steps down: the badge is led with a spinner while anything is out to a remote, so a key on a large checkout is never a second of nothing happening. It stops while the *rebase or merge* question is on screen — nothing on this machine is working while you are reading two options. It works the other way round too. A worktree exists for the conversations in it, so deleting the last one — `X` on the row, `X` or `^X` in the archive, `archive.sweep` — removes the checkout as well, and the dialog says so: which directory, that the branch stays, and how many uncommitted changes go with it. `X` on a worktree's own row does the same. The repository itself is never removed; `X` on its heading only takes the row off the list. Pictures pasted into a conversation go with it as well. ## Generated branch names and commit messages `git.branch.new` has a model name the branch and shows you the name before creating it; `git.commit` writes a message from the staged diff and shows it before committing. Every prompt behind them is a setting, in two layers: `*.instructions` appends to the built-in prompt, `*.prompt` replaces it. ```toml [options] "git.branch.prefix" = "feature/" "git.branch.instructions" = "Start with the Jira key when the message mentions one." "git.commit.instructions" = "Follow Conventional Commits." "gen.model" = "anthropic/claude-haiku-4-5" # what writes names and messages ``` ## Archiving `x` archives. Everything is kept, every message and the file on disk; it simply leaves the panel. It asks nothing, because it takes nothing away. Archived conversations are not rows in the sidebar at all: `^F` from anywhere, or `a` in the panel, opens the archive as a popup of its own, with filtering, ticking, restore and export. `archive.sidebar = true` puts a count row back for anybody who wants one. `X` deletes: the file goes and there is no undo, so it always asks, in numbers, saying how many messages and which project — and which worktrees are left empty by it and go too. `ui.confirm_destructive = false` turns off every such dialog, everywhere. Three time settings, none of which deletes on a timer: `archive.auto_days` archives what has gone idle (reversible, so it may happen on its own), `archive.retention_days` only ever counts, and `archive.sweep` is the same number with a person behind it. ## Confirmations Anything that cannot be undone asks first, and nothing reversible does, with no exceptions on either side: a dialog that appears for some deletes and not others is a key you cannot predict, and one charged for an undoable action teaches you to dismiss dialogs. The cursor starts on the answer that changes nothing, the destructive answer wears the error colour, and the question says what is at stake. `y` and `n` answer outright, `Esc` means no. ## The plan strip The foot of the sidebar keeps one row per usage limit that matters, with `⇥` stepping up how much is shown. `^L` opens the full panel. Settings and details are in [Models](/docs/models). ==================== # Models URL: https://neoswarm.dev/docs/models ==================== ## Drivers | Driver | Needs | | --- | --- | | `claude-cli` | Your existing `claude` login | | `codex-cli` | Your existing `codex` login | | `antigravity` | Your existing `agy` login, including a Google AI subscription | | `anthropic` | `ANTHROPIC_API_KEY` | | `openai-compat` | Any endpoint speaking the OpenAI shape | | `google` | `GEMINI_API_KEY` | | ACP agents | `cursor-agent`, `grok` and `gemini` speak the Agent Client Protocol; a fourth ACP agent is a catalogue line, not code | ## The picker `^P` opens two panes: your providers on the left, the models each serves on the right. It works mid turn too; the running agent is told and thinks the rest with the new model. ``` PLANS │ ❯ Claude Opus 5 Frontier Most capable ✳ Claude ✓ │ Claude Sonnet 5 Balanced Everyday tasks ⬢ Codex ✓ │ Claude Haiku 4.5 Fast Quick answers API KEYS │ ▸ 5 superseded ✳ Anthropic ! │ LOCAL │ ▲ Ollama │ ``` The rail is grouped by what a turn costs you: **PLANS** are subscriptions you already pay for, **API KEYS** bill per token, **LOCAL** costs nothing and needs nothing. The marker at the right edge: `✓` it will work, `!` it needs a key or its CLI is missing, `⨯` no driver provides it. Typing filters. `⇧⇥` and `←` reach the provider rail, `⇥` and `→` come back. The picker's own actions are chords, because every bare letter a picker takes is a letter the filter can never contain: | Key | Does | | --- | --- | | `^S` | Sign in to the provider the rail is on | | `^R` | Ask that endpoint again, after adding a key or starting a local server | | `^A` | Add a model by id, for one the catalogue has never heard of | | `^D` | Remove one you added | Every provider lists models whether or not you have a key, because the list is how you decide whether signing in is worth it. Once a key is present, the endpoint's own `/v1/models` decides what exists; a model the endpoint does not return is dropped even if the catalogue lists it. ## Plans `claude-cli`, `codex-cli` and `antigravity` drive the vendor's own CLI as a provider. None needs an API key, none stores a token, and none is offered unless the program is actually on your `$PATH`. Three things worth knowing: - They are agent drivers, running their own loop with their own tools and sandbox, so neosh's tool registry is not in play on those turns, and neither driver overrides the CLI's own approval policy. The place to change that is the CLI's configuration. - `codex exec --json` has no token deltas, so a Codex turn shows its tool activity live and then its answer all at once. That is the CLI's shape, not a shortcut. - `agy` headless has no way to ask you anything, so it denies whatever its own `permissions.allow` does not already cover. Full access — neosh's default, and `⇧⇥` from anywhere — is the mode where an Antigravity turn can act; in any other mode a turn that was blocked says so in the transcript rather than quietly returning nothing. Its reasoning effort is part of the model id (`gemini-3.8-flash-high` and `-low` are two entries), because that is how `agy models` publishes them, and `^E` carries one knob of its own: **Mode**, where `Plan` is Antigravity's own plan mode. A provider whose CLI is not installed still lists what it offers, greyed out, with the command that would fix it at the top. ## The ladder Every catalogue model sits on a rung: Frontier, Balanced or Fast. Three commands move along it, none with a default key; run them from `^K` or bind your own: ``` model.upgrade one rung up, same provider model.downgrade one rung down model.line opus the current model in a product line, wherever it is reachable ``` `upgrade` and `downgrade` hold the provider fixed on purpose: "give me something cheaper" is a question about the model, and answering it by also changing your billing would answer a question nobody asked. ## Model options `^E` shows everything the current model can be told, on one panel: effort, thinking, fast mode, context, and whatever the driver invented. Options arrive as descriptors attached to the model, so a provider plugin that invents a knob gets a working picker for free. Each change applies as you move; switching to a model without an option drops the setting rather than sending a value the driver would reject. ## Adding an endpoint ```toml [[providers]] id = "local" driver = "openai-compat" display_name = "llama.cpp" base_url = "http://localhost:8080/v1" auth = { kind = "none" } ``` ## Keys and secrets A key is looked for in four places, in order: one you typed this session, the OS keychain, a helper program, then the environment. Typed wins, because you typed it after seeing what the environment gave you. ```toml auth = { kind = "env", var = "MY_API_KEY" } auth = { kind = "command", argv = ["pass", "show", "anthropic/api-key"] } auth = { kind = "cli", program = "codex", login = "codex login" } auth = { kind = "none" } ``` Anything on `$PATH` works as a helper: `pass`, `op read`, `bw get`, your own script. Only the first line of its output is used. The key prompt itself is the host's, not a plugin's: nothing is echoed, and no plugin ever sees what you typed. There is no API call that returns a key, only ones that report where it came from, so a plugin cannot leak what it cannot read. Keys go to the OS keychain where one is available, or are held for the session where not; neosh tells you which happened and never writes a key to a file it controls. ## What the plan has left `^L` opens live gauges per limit, with a sparkline of how each has moved, and under them where the time actually went: tokens and their API-equivalent cost by day or hour, per model, read from the agents' own transcripts, so a turn you ran outside neosh is counted too. An allowance cannot be counted, only reported, so neosh keeps whatever the vendor last said and shows how old that is. One row per limit window, never collapsed to the worst of them, with `▸` marking the one that binds and the colour being the vendor's own grade. ```toml [options] "usage.sidebar" = true # the strip at the foot of the sidebar "usage.warn_at" = 90 # say something the first time a limit passes this "usage.days" = 30 # the span the panel opens on "usage.poll" = true # ask while nothing is running "usage.show_tokens" = true ``` `usage.poll` is the one worth a sentence: at rest is exactly when you ask "how much is left", and nothing reports it at rest, so neosh asks the vendor with the CLI's own login. The token is read at request time, never written anywhere, never logged, and structurally absent from every event a plugin can see. What crosses that boundary is percentages. ==================== # Machines URL: https://neoswarm.dev/docs/machines ==================== The swarm is on by default: this machine has an identity, dials the machines it is paired with, and listens on `0.0.0.0:7717`, so adding it from another computer works before anything is configured. What stays consent is membership. A machine not on the allow-list gets nowhere regardless of what it can reach. ```toml [swarm] enabled = false # the whole switch listen = "" # or just dial-only: joins machines without being joinable ``` ## Pair two machines 1. Press `^J` on either machine and pick **Add a computer**. 2. Type the other machine's address. You are shown what is actually there: its name, version and key fingerprint, read from the far end rather than typed by you. ``` Add linux-box? 127.0.0.1:7739 · macos · neosh 0.1.0 1ca3 c856 8f4c 6584 Check that fingerprint matches what that computer shows under `This computer`. ``` 3. Check the fingerprint against what that computer shows under **This computer**, then confirm. 4. The other machine announces that somebody is asking, and somebody there presses `^J` and allows it. Two confirmations, one per machine, because authorising a computer to steer your agents is a decision each side gets to make. Nothing restarts, and nothing is written to your `config.toml`: paired machines live in the state directory, and `^X` in the list removes one. ## Or by hand `neosh swarm-id` prints this machine's identity and the lines to paste elsewhere: ```toml [swarm] name = "mac-studio" # what the others call you; the hostname if unset listen = "100.71.4.9:7717" # omit to be dial-only [[swarm.peers]] addr = "linux-box:7717" id = "453ff38823de6515…" # from `neosh swarm-id` over there name = "linux-box" ``` `id` is the authorisation. An address says where to look; the key says who it is, and a machine that cannot prove that key is refused however it reached you. A node is its ed25519 public key, and the network is never trusted to say who may steer an agent. A peer entry you wrote by hand cannot be removed with `^X`, because that line would come back on the next reload. Changing `listen`, `name` or the trust settings takes a restart; pairing itself takes effect immediately. ## Watching a connection Every paired machine is a row in `^J` from the first frame, and the row says where the link stands: `connecting…` for one being dialled that has never answered, with a `try 4` once it has failed a few times, `reconnecting` for one that was here and dropped, `disconnected` for one nothing is dialling, and the conversation count for one that is up. Reconnects back off from a second up to half a minute and reset the moment a dial lands, so a machine that reboots is back in seconds. The list updates live while it is open, and the `This computer` row says whether this machine is `listening`, `not listening`, or `dial-only`. Two verbs on any row: | Key | Does | | --- | --- | | `^R` | Dial it again now, rather than waiting out the retry delay | | `^D` | Disconnect: close the link and stop dialling, keeping the pairing. `^R` takes it back | Disconnect holds in both directions, so a peer that dials in is turned away until you say otherwise, and it lasts until a reconnect or a restart. Unpairing with `^X` is the stronger verb, for a machine you are done with rather than done with for now. ## Three kinds of trust ```toml accepts_commands = true # may peers steer agents here at all accepts_approvals = false # may peers answer permission prompts here accepts_shells = false # may peers open a shell here ``` Steering an agent is a message. Approving one is a write to this machine's disk. Opening a shell is a prompt with your credentials at it and nothing above it to say no. Each is a different kind of yes, so each is asked for separately and the last two are off by default. A build machine that should be watched and not touched sets `accepts_commands = false` and is visible, read-only, to everyone. Whatever a machine advertises, it checks again every time: a capability list is a courtesy to the other end's menus, never the enforcement. ## What you see **A project is one row, wherever its checkouts are.** Projects are matched by their normalised git remote rather than by path, so `/Users/me/dev/neosh` here and `/home/me/src/neosh` there are one repository — one row, with everything under it: ``` PROJECTS ────────────────────────────────── ▾ neosh 6 ▸ Chasing the flake 12m · Rework the tab bar @ ▾ ⎇ fix/composer-paste 2 · Try the grapheme path ▾ ⎇ fix/tab-strip @ 1 · Nearly there @ ▾ api @ 3 · Rate limiting @ ``` Its conversations here and its conversations over there sit in the same list under it. Every other checkout — a worktree here, a worktree there, the main checkout there — is a row one level down, named by its branch. `api` is a repository this machine has no clone of at all, which is still somewhere you can see and start work. The marker is one column of **`@`**, and its **colour is the link**: | | | | --- | --- | | green | connected — you can open it, steer it, start things on it | | amber, pulsing | being dialled, or waiting for somebody to allow this computer over there | | dim | nothing is dialling it | `@` rather than a cloud, because in a terminal that is already the word for it: anyone who has typed `ssh you@box` reads `@` as *over there, on that host*, while a cloud reads as a cloud *service*, which is the wrong idea about a laptop on the same desk. A repository row wears one only when the repository is **not** here as well. What is true of nearly every row in a swarm of two machines does not earn a column on each of them; where it is news is a repository you have not cloned, which is the row you would otherwise press `↵` on expecting your own files. Which computer, and what the link is doing in words, is on the **key card** — pause on the row and it appears beside the panel: `on mac-studio · connected`. The name is not on the row itself, because a block of twenty rows that are all on `mac-studio` does not need to say so twenty times, and the columns it used to take are the ones the conversation's own name needs. Rows for a machine that has gone quiet stay where they are. They were real a moment ago and probably still are; what changed is that you cannot reach them, and a list that silently shortens is one you cannot tell from a list that never had them. `↵` on a remote conversation opens it: its history, then everything as it happens. `i` says something to it and `^C` asks its turn to stop. Three more keys work on any row that is about another machine, so the verbs are beside the thing they act on rather than behind `^J`: | Key | Does | | --- | --- | | `t` | A **terminal** in this project — here, or on the machine the row is on. See below | | `c` | Connect, or reconnect, to that machine | | `C` | Disconnect from it. `c` takes it back | `^J` is still where a machine is added, renamed or removed. ## A shell over there `t` on a row, or `swarm.shell` from `^K`, opens a tab with a **shell on that machine**, in that project's directory. It is an ordinary terminal pane: every key goes to the child, `^C` interrupts *it*, `^D` ends its input, and `` is the way out. The shell runs there and the terminal emulator runs here — which is how ssh is arranged, and for the same reasons: full-screen programs work, and the side that is drawing is the side that knows how wide it is. It is **off by default** and asked for separately from steering: ```toml [swarm] accepts_commands = true # peers may steer agents here accepts_shells = true # peers may open a shell here ``` Starting an agent over there is a thing with a permission layer over it, a transcript, and somebody who can read afterwards what it did. A pty is a prompt — your shell, your environment, your credentials, and nothing above it to say no. The machine it runs on says so on screen each time one is opened, and closes every one of them when the link that asked for it goes away. ## Starting something over there With a machine paired that has this project, `^N` asks **which computer** first — this one, or any of them, each with its marker and the reason it cannot be used when it cannot. `This computer` is the first row and the cursor starts on it, so `^N ⏎` is what it always was. Pick another and you get the path field pointed at that machine, which is the same thing typing `linux-box:` into it would have got you. With no machine paired the question is not asked at all, and `^N` is the single key it has always been. Nor is it asked when the machines you have are connected but none of them has a checkout of the repository you are in: there is one possible answer, and a panel that appears to confirm it is a keypress spent on nothing. `n` on a project that is only on other computers skips straight to it: the row already knows which machines have it and where on each, so with one there is nothing to ask. ## What moves, and what does not An agent belongs to the machine it was started on. Its files, shell and credentials are there, so what travels is descriptions of agents and requests to their owners, never the agent itself. The owner may refuse anything, whatever it advertised. ## Networking Plain TCP, and no NAT traversal of its own. On a LAN it needs nothing; across the internet, run Tailscale, Nebula, NetBird or ZeroTier underneath and use whatever address that gives you. Hole punching is a hard problem those projects have solved properly. ==================== # Writing a plugin URL: https://neoswarm.dev/docs/plugins ==================== > In a hurry? [Docs for agents](/docs/agents) sets up your coding agent to write plugins for you, with one command. ## Your first plugin Create a folder under `~/.config/neosh/plugins/`: ``` my-plugin/ ├── plugin.toml └── main.ts ``` ```toml name = "my-plugin" version = "0.1.0" entry = "main.ts" description = "Says hello" ``` ```ts import type { PluginContext } from "@neosh/api"; export async function activate({ neosh, subscriptions }: PluginContext) { const d = await neosh.cmd.register("my.hello", () => neosh.notify("hi"), { desc: "Say hello", }); subscriptions.push(d); // disposed when the plugin unloads await neosh.keymap.set("chat", "", "my.hello"); } export async function deactivate() {} // optional: runs on unload and shutdown ``` Reload with `^R` and press `^H`. Plugins in your config directory are discovered as they are, so every save is live. Plugins are transpiled, not type-checked, at load. `neosh init` writes the API types next to your config, emitted by the binary you are running, so `npx tsc --noEmit` checks against the exact version you have; run it, since it is the only thing that catches a type error before it becomes a runtime one. ## The manifest, in full ```toml name = "acme-tasks" version = "0.1.0" entry = "main.ts" description = "A task panel" # Declared up front, and enforced. Everything not listed here (windows, buffers, # keys, floats, options, vars, drawing, reading) needs nothing declared: it is no # more privileged than what the person sitting there can already do. permissions = ["vcs_write"] # Plugins this one builds on. Each loads and activates first, and # `import … from "plugin:"` resolves to it. A name nothing provides # fails at startup with the name in the message. requires = ["sidebar"] # Soft ordering: load after these if present, without needing them. after = ["usage"] # What this plugin offers others: listed by `ext.points()`, and how a # contribution to a point nobody reads gets reported instead of ignored. [provides] points = ["acme.tasks.section", "acme.tasks.action", "acme.tasks.decoration"] kinds = ["acme.tasks"] vars = ["acme.task.due"] # Omit for a plugin that loads at startup. Present, it is held until one of # these fires; the command is registered on its behalf and the press replayed. [activation] on_command = ["acme.tasks.toggle"] on_event = ["neosh.win.enter"] on_kind = ["neosh.sidebar"] ``` ### Permissions | Permission | Grants | | --- | --- | | `tools` | Register a tool the model can call | | `providers` | Register a model provider driver | | `hooks_blocking` | Take a hook that can rewrite or veto. An observer needs nothing | | `raw_cells` | Claim a raw cell surface | | `vcs_write` | Branch, checkout, stage, commit, worktree | | `notify` | Raise an alert outside the terminal. Drawing in the corner stays free | These are plugin permissions, not the agent's permission mode. Those govern what the model may do; the model reaches git and the rest through registered tools, gated like every tool. ## What the runtime gives you A bare `deno_core` sandbox: the ECMAScript built-ins, `console`, `queueMicrotask`, and timers. No `fetch`, no `TextEncoder`, no filesystem, no subprocess. Anything with an effect goes through the neosh API, which is what makes hooks and permissions mean something. Imports resolve for relative paths, `@neosh/api`, its submodules, and `plugin:`. There is no package resolution; bundle third-party code into your plugin. Prefer `neosh.timer` over the timer globals: the globals cannot know who called them, so they are not cancelled when your plugin unloads, and an interval that outlives its plugin doubles up on every reload. ```ts const stop = neosh.timer.every(1000, () => { /* … */ }); subscriptions.push(stop); const redraw = neosh.timer.debounce(150, () => render()); ``` ## Who wins a name One rule for keys, commands and highlight groups: a bundled plugin offers defaults, a plugin you installed is a choice, and your own `init.ts` is the last word. A lower tier never takes what a higher one holds, and within a tier the later registration wins. A command name a higher tier holds is not refused to the lower one: the registration waits in the wings and comes back when the higher tier lets go. ## Installing and publishing A plugin is published by being a git repository with a `plugin.toml` at its root. There is no registry; a URL is already a globally unique name, and it works for a fork, a private repository and the branch you are writing. ```sh neosh plugin add https://github.com/someone/neosh-thing neosh plugin list neosh plugin update # fast-forward pull, all of them neosh plugin remove thing ``` `add` clones, validates the manifest with the same code startup uses, and only then moves it into place, so a broken plugin fails now with a sentence rather than at your next startup as a log line. Two places, and they mean different things: `~/.config/neosh/plugins/` is yours, for plugins you are writing, live on every save and never touched by `plugin remove`. Installed plugins live under the data directory as checkouts neosh manages. `plugin_dirs` in `config.toml` adds any other directory. ## The worked examples The sidebar, the model switcher, git, the palette and approvals live in `plugins/builtin/` in the repository. They are ordinary plugins that happen to be embedded in the binary: no private API, no exemptions, and CI type-checks them against the published `@neosh/api`. Read them; they are the reference for everything the API can do. Built one? Put it in the [showcase](/plugins). One file, one pull request. ## Next - [The plugin API](/docs/plugin-api) walks every namespace. - [Panels and extension points](/docs/panels) is how plugins compose. - [Docs for agents](/docs/agents) if an agent is writing the plugin with you. ==================== # The plugin API URL: https://neoswarm.dev/docs/plugin-api ==================== `neosh init` writes the full typed API to `~/.config/neosh/types/`, generated by the binary you are running, and machine-readable copies live at [/llms-full.txt](/llms-full.txt). This page walks the surface. ## Buffers and windows `buf` creates and edits text buffers; `win` places them and moves cursors. ```ts const buf = await neosh.buf.create({ name: "[tasks]", scratch: true, kind: "acme.tasks" }); await neosh.buf.setLines(buf, 0, -1, ["one", "two"]); await neosh.buf.appendText(buf, "streamed text"); neosh.buf.onChange(buf, () => { /* redraw */ }); const win = await neosh.win.open(buf, "right", { size: 40 }); await neosh.win.setCursor(win, 3, 0); await neosh.win.scrollTo(win, null); // null follows the tail; 0 is the top const open = await neosh.win.ofKind("neosh.sidebar"); await neosh.win.resize(win, 44); // in place, keeping focus ``` A buffer's `kind` is its public identity: what keymaps scope against, what `win.ofKind` finds, and what activation events fire on. Publish one for anything more than a scratch buffer. `float` is the same for floating windows: `float.open(buf, opts)` with anchors, borders, titles, `modal` (takes global keys out of the chain) and `closeOnBlur`. `focus` is the focus stack: `push`, `pop`, `current`, `onChange`. ## Columns are UTF-8 byte offsets Every column in the API is a byte offset, matching Neovim, so display-width maths stays in the frontend. JavaScript's `String.length` counts UTF-16 units and is wrong for anything a model produces. ```ts import { width, clipToWidth, padToWidth, byteLength, byteOffsets } from "@neosh/api"; width("日本") // 4 columns, 2 code points padToWidth(name, 20) + "│" // a rule that lines up await neosh.ns.mark(ns, buf, row, 0, { hlGroup: "Title", endCol: byteLength(line) }); ``` These are synchronous and use the same measurement the renderer does, so your layout and the frame agree by construction. ## Marks and highlights `ns` is extmarks: create a namespace, put marks with highlight groups, virtual text and line highlights on rows, clear them. Redrawing a row means clearing its marks first; a mark clamps rather than dies when its line is replaced. `hl` defines and reads highlight groups. Groups are semantic and owned; link to the theme's rather than picking colours, and pass `default: true` to define only if nobody has: ```ts await neosh.hl.define("Acme.Due", { link: "Status.Unread" }, { default: true }); await neosh.hl.define("MyPlugin.Working", { link: "Status.Streaming" }); // sweeps, no timer to own const { resolved } = await neosh.hl.get("Normal"); ``` ## Commands and keys ```ts await neosh.cmd.register("acme.count", () => ({ n: 3 }), { desc: "Count things" }); await neosh.cmd.exec("acme.count"); // the key press: fire and forget const { n } = await neosh.cmd.call<{ n: number }>("acme.count"); // the question: returns the handler's answer await neosh.keymap.set("chat", "", "acme.count", { desc: "Count" }); ``` A command handler receives `(args, key, here)`: the arguments, the `KeyContext` of the press if there was one, and the whole API bound to the terminal the key was pressed in. `keymap.capture(win, command)` routes keys nothing claimed to a command while a window is focused, for widgets that need raw input. ## The agent `agent` is the conversation surface: send and cancel, drive any conversation by id, read and set the model, watch everything. ```ts await neosh.agent.send("run the tests"); const id = await neosh.agent.command({ command: "new_session", title: "the tests" }); await neosh.agent.command({ command: "send", text: "run the suite" }, id!); await neosh.agent.command({ command: "interrupt" }, id!); neosh.agent.onToken((e) => { /* e.session, e.turn, e.text */ }); neosh.agent.onToolStart((e) => { /* e.call */ }); neosh.agent.onTurnEnd((e) => { /* join on endings */ }); ``` `onTurnEnd` plus `agent.command` is an orchestrator: fan work out over several conversations and the screen never moves. Also here: the composer (`setDraft`, `onComposerChange`), attachments (`attach`, `detach`), model selection (`selection`, `setSelection`, `listModels`, `onSelectionChange`), and credentials (`credentials`, `setCredential`, which opens the host's own prompt and never returns a key). ## Tools, hooks and providers `tool.register` gives the model a tool, gated by the permission layer like every tool (needs `tools`). `hook.register` observes or, with `hooks_blocking` and `{ blocking: true }`, rewrites and vetoes; `turn.route` is the hook that decides which model answers a turn. `provider.register` adds a model provider driver (needs `providers`). ```ts await neosh.hook.register("turn_route", (p) => { if (p.hook !== "turn_route" || !p.text.startsWith("quick:")) return { action: "continue" }; return { action: "modify", payload: { ...p, selection: { instance: "local", model: "small", options: [] } } }; }, { blocking: true }); ``` ## Git and generation ```ts const status = await neosh.git.status(); // rejects with not_found outside a repository await neosh.git.diff({ kind: "staged" }, { stat: true }); await neosh.git.commit("fix: the thing"); // needs vcs_write; reads need nothing const branch = await neosh.gen.field( "Return JSON with one key: branch.\n\nUser message:\n" + text, "branch", ); ``` `gen` runs one-shot generation outside any conversation: nothing enters session history, so asking for a commit message does not change what the agent believes it was asked to do. `gen.json` tolerates what models actually return, fences and preambles included. `gen.field` tolerates the one thing it cannot: an answer that is a single value often arrives *as* that value — `fix/composer-paste`, with the object the prompt asked for left off — and naming the key makes both the same answer. Use it for a value you would know was wrong on sight, which a branch name is; a thread title is any short line, and so is a refusal, so there the envelope is the evidence the question was answered at all. Make your prompts options rather than constants, in the two layers the git plugin uses: `*.instructions` appended, `*.prompt` replacing. ## Sessions and views ```ts await neosh.session.list(); // what the user works in await neosh.session.list({ includeArchived: true }); await neosh.session.archive(id); // reversible; archive is the verb for "done with it" await neosh.session.close(id); // deletes the file; gate behind confirmDestructive neosh.session.onChange((e) => { /* which terminal moved, and to what */ }); ``` `view` is the terminals: `list`, `current`, `onOpen`, `onClose`, and `view.at(id)`, which is the whole API bound to one terminal, so a window opened through it lands there. Most plugins never need it; windows opened in answer to a key are routed for you. The exception is a dock, which exists once per terminal, so open one per view in `view.onOpen`. ## Editing text Your plugin's text field behaves like the composer by asking the same questions: ```ts await neosh.edit.move(win, "word_left", { select: true }); // shift-and-arrow, in one call await neosh.edit.apply(win, { kind: "insert", text: "hi" }); await neosh.edit.selectShape(win, "inclusive"); // a normal mode; "line" for V await neosh.edit.cursorShape(win, "block"); // give it back when your mode ends await neosh.edit.copy(await neosh.edit.selection(win)); // OSC 52: reaches the machine you sit at ``` Motions are verbs the core resolves rather than positions you compute, because grapheme and word boundaries are genuinely hard and nobody should get them right twice. ## Remembering things Three stores, by who has a reason to look: ```ts // state: yours alone, keyed by plugin id, survives restarts. Arrangement, not configuration. await neosh.state.set("favorites", ["/home/me/project"]); // vars: shared, scoped to the workspace, a project, a conversation, a buffer or a window. import { projectScope } from "@neosh/api"; await neosh.vars.set(projectScope(cwd), "sidebar.favorite", true); neosh.vars.onChange((e) => { /* whoever changed it, including you */ }); // opt: settings the user wrote. Declare yours and config.toml can set it. await neosh.opt.declare({ name: "acme.enabled", type: { type: "bool" }, default: true, description: "…" }); ``` Default to `state`; reach for `vars` when somebody else genuinely needs the value. A var write is a file write for workspace, project and session scopes; buffer and window vars are in memory and die with their buffer or window. Nothing secret goes in any of them. ## Events ```ts await neosh.event.emit("acme.indexed", { files: 412 }); neosh.event.on("acme.indexed", (e) => { /* e.data, and e.from stamped by the host */ }); ``` Broadcast, with no reply by construction. When you need an answer, register a command. The workspace's own events arrive the same way, `from: "neosh"`: | Event | Data | | --- | --- | | `neosh.ready` | Once every plugin has loaded, and again after a reload | | `neosh.win.enter`, `neosh.win.leave`, `neosh.win.open` | `{ win, buf, kind }` | | `neosh.win.close` | `{ win }` | | `neosh.cursor` | `{ win, row, col }` | | `neosh.mode` | `{ mode }` | | `neosh.viewport` | `{ win, width, height }` | `event.on(name, cb, { kind: "neosh.sidebar" })` keeps only the events about one kind. ## Talking to the person Three calls, split by whether the user asked: ```ts neosh.notify("copied /home/me/proj"); // a reply to a key; does not stack; never leaves the terminal neosh.progress("acme.index", "indexing…"); // a state, keyed and replaced in place neosh.done("acme.index"); // put this in a finally await neosh.alert("acme", "index is stale", { session }); // news; may leave the terminal; needs `notify` await neosh.ask([{ /* UserQuestion */ }]); // a real question, same panel the agent's questions use ``` Whether an alert leaves the terminal is the host's decision, never yours: only it knows who is looking at what. `ask` resolves to `null` when nobody answered, which is an answer, not an error. ## Everything else | Namespace | What it is | | --- | --- | | `ext` | Contribution points: `contribute`, `list`, `onChange`, `points()`, `plugins()`. See [Panels](/docs/panels) | | `status`, `hint` | The status strip and the shortcut row. Keyed entries with priorities | | `quota` | Usage gauges: `list`, `refresh`, `history`, and `report()` for a provider you wrote | | `swarm` | The other machines: `nodes`, `agents`, `command`, `pair`, `probe`, `onStream` | | `permission` | The agent's permission mode. `setMode` lasts for the session only | | `rtp` | `add` another plugin directory, from `init.ts` only, before discovery | | `path` | `complete(query)` for directory completion in your own prompts | | `timer` | `every`, `debounce`; cancelled with your plugin, unlike the globals | | `log` | Levelled logging that ends up in the host's log, never on screen | | `ui` | Raw cell surfaces, for the one plugin in a hundred that needs to paint cells (needs `raw_cells`) | And `@neosh/api/ui` ships the widgets everything above is built from: `picker`, `railPicker`, `prompt`, `confirm`, `confirmDestructive`, and `ListPanel`. They are ordinary API consumers; vendor your own copy and it behaves identically. [Panels and extension points](/docs/panels) covers them. ==================== # Panels and extension points URL: https://neoswarm.dev/docs/panels ==================== ## The idea Four mechanisms, and between them you should not have to fork a bundled plugin to change it: bind a key by buffer kind, contribute rows and verbs through named points, recolour through owned highlight groups, and hear what happens on the event bus. A new panel of your own gets all four from `ListPanel` for the price of a kind and a rows function. ## Bind a key inside a panel you did not open A panel declares what it is with a buffer kind, and you bind against the kind rather than a window id that is private and changes on every toggle: ```ts // In your init.ts. `x` archives by default; this replaces it, in the sidebar only. await neosh.keymap.set("chat", "x", "acme.mine", { scope: { kind: "buf_kind", name: "neosh.sidebar" }, }); ``` Resolution is window, buffer, kind, then global, first match winning. Your binding is ordinary: `^Z` lists it under the panel's section, and the panel's default loses to it. ## Contribute rows, verbs and marks A contribution point is a name a plugin agrees to read. The sidebar reads three, and they are the pattern every panel follows: ```ts // Rows in the column. Re-contributing under the same id replaces, so this is also the update. await neosh.ext.contribute("sidebar.section", "todo", { title: "ACME", before: "add", // a named slot, or another section's id rows: [{ text: "Ship the thing", command: "acme.open", args: ["thing"] }], }); // A mark on a row the panel already draws, keyed by what the row is about. // Data, never a row-renderer callback: one slow decorator would be a panel that lags on j. await neosh.ext.contribute("sidebar.decoration", `prs:${cwd}`, { target: { project: cwd }, // or { session: id } badge: { text: "2 PRs", hl: "Accent" }, }); // A verb on a row. The panel binds the key and invokes your command with the row under the cursor. await neosh.ext.contribute("sidebar.action", "touch", { key: "t", label: "touch", command: "acme.touch", on: "session", // or "project", "any", "custom", "custom:
" }); ``` `on: "custom:todo"` is a verb about **your** rows — the ones the `todo` section above put there — and it is what you want nearly every time. Bare `custom` is every contributed row in the column, which stops meaning "mine" the moment a second plugin has a block in it. Several plugins may ask for the same key on their own rows and all of them are right: the panel binds it once and sends the press to whichever of you the cursor is actually over, and the key strip at the foot prints the one that would fire. Your contributions go when your plugin does, so `plugins.disabled` takes your rows with it. Declare the points you read under `[provides]` in your manifest; a contribution to a point nobody reads is reported at startup with the nearest real name, `did you mean "sidebar.section"?`. Reading a point in a panel of your own is `ext.list(point)` plus a redraw on `ext.onChange`, and that is the whole protocol. ## A panel of your own `ListPanel` gives you everything above for a kind and a `rows` function: ```ts import { ListPanel } from "@neosh/api/ui"; const panel = await ListPanel.create(neosh, { kind: "acme.tasks", dock: "right", size: () => 30, rows: () => tasks.map((t) => ({ text: ` ▸ ${t.title}`, value: t })), key: (t) => ({ task: t.id }), // what decorations target kindOf: () => "task", // what a contributed action's `on` may name onOpen: (t) => openTask(t), }); subscriptions.push({ dispose: () => panel.dispose() }); ``` That is a buffer of kind `acme.tasks` in a dock; `acme.tasks.down`, `.up`, `.open`, `.toggle`, `.focus`, `.refresh`, `.cursor` and `.rows` as commands bound at kind scope so `init.ts` can move them; `acme.tasks.section`, `.action` and `.decoration` read exactly as the sidebar reads its own; the cursor published as a buffer var and an event; and a redraw when any of it changes. ## The widgets `@neosh/api/ui` is built entirely on the public API; nothing in it is privileged. ```ts import { picker, prompt, confirm, confirmDestructive, railPicker } from "@neosh/api/ui"; const branch = await picker(neosh, rows, { title: "Switch branch", width: 76 }); // null when dismissed. `source` makes it a finder that re-queries per keystroke; // `freeform` accepts what was typed; `onKey` + `ownKeys` put verbs on rows. const name = await prompt(neosh, "Branch name", { initial: suggested }); if (!(await confirmDestructive(neosh, `Delete ${name}?`, { yes: "Delete", no: "Keep", detail: [`${count} messages, in ${project}.`, "Archiving keeps every word of it."], }))) return; ``` `confirmDestructive` reads `ui.confirm_destructive`, starts the cursor on the answer that changes nothing, and draws the other in the error colour, so the setting means one thing everywhere without your plugin knowing it exists. The bar is irreversible, not merely significant: closing a panel asks nothing, deleting a file always asks. `railPicker` is the two-pane shape the model switcher is built from, for when one list would answer two questions at once. Widget keys come from `ui.keys.*` and are claimed window-scoped while open; you get that for free. `pager` is the read-only one: rows you look at rather than choose between, in a float that scrolls when the screen is shorter than the content. ```ts import { pager } from "@neosh/api/ui"; await pager(neosh, patch.split("\n"), { title: " changes ", width: 100, height: 30, marks }); ``` `height` is a **ceiling, never a size** — a float is given whatever the screen has, so anything you ask for is a wish. What you get back is the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys), the mouse wheel, a bar down the right border while anything is hidden, and a legend on the bottom border that cannot scroll away or be clipped. On a panel of your own, `scroll: true` on the float is the whole of it: ```ts const win = await neosh.float.open(buf, { height: { kind: "max", n: 30 }, scroll: true }); ``` That adds one scope to the keymap chain while the float has focus — `{ kind: "buf_kind", name: "neosh.scroll" }` — which is where those bindings live. It sits *below* your own buffer's kind, so a key you bind on your panel is still yours, and *above* `global`, so it works under `modal` too. Do not set it on a panel with a cursor of its own: a picker's filter would lose `j` and `G` to it. Anything a float draws on its bottom edge is `footer`, which is where a key strip belongs — written as the last row of the buffer it is both the row that scrolls away and the first row a short terminal clips. ## Building on another plugin ```ts // Typed and free of round trips, for a plugin you `requires` in the manifest. // This is the very module the host activated, not a second copy of its source. import { api as sidebar } from "plugin:sidebar"; const row = sidebar.cursor(); // Through the host, for a plugin you would rather not depend on. const row2 = await neosh.cmd.call("sidebar.cursor"); ``` `init.ts` loads before every plugin, so a static `plugin:` import there finds nothing; use a dynamic import inside `neosh.event.on("neosh.ready", …)`. ## Terminals, docks and modality Most windows are routed for you: a float anchored to a window goes where that window is, and anything opened in answer to a key lands where the key was pressed. A dock is the exception, because one that exists once exists in one terminal: ```ts const panels = new Map(); neosh.view.onOpen(async (view) => panels.set(view, await makePanel(neosh.view.at(view)))); neosh.view.onClose((view) => panels.delete(view)); ``` Inside a command handler the third argument is already the API bound to the right terminal: `here.win.open(...)` lands where the person pressing the key is looking. A panel you are in the middle of using can take the keyboard: `FloatConfig::modal` takes global keys out of the chain, and a key nothing claimed is swallowed rather than reaching the composer behind the float. A modal that borrows a global key to open itself owes a binding to close itself. ## Health `neosh.ext.points()` lists every point with who reads and writes it; `neosh.ext.plugins()` every plugin with its manifest and what became of it, loaded, held or failed; `hl.list()` every group with its owner. `plugins.list` in `^K` draws all of it: the checkhealth of this workspace. ==================== # Keys URL: https://neoswarm.dev/docs/keys ==================== **Nothing here is fixed.** Every key below is an ordinary binding to a *command name*, in the same table your `init.ts` writes to. Setting the same key replaces it; `neosh.keymap.del` removes it; a capability whose key you delete still runs from `^K` by name. `^Z` lists the live registry, which is the truth — this page is what ships. See [keymaps](/docs/keymaps) for how to change any of it. Every default is a key your terminal already sends: Ctrl with a letter, `⇧⇥`, `⏎`, `⌫`, `Esc`. Nothing needs `fn` or Option-as-Alt. Arrows, `PgUp`/`PgDn`, `Home` and `End` are bound wherever they mean something and are **never the only way** to do anything. ## Chat | Key | Does | | --- | --- | | `⏎` | Send. While a turn is running, **steer** it: the message is taken in at the next gap | | `⇧⏎` | Newline, so a pasted snippet stays one message | | `Esc` | Interrupt the turn in this conversation. The conversation survives it | | `^Y` | Take the last thing you queued back into the composer, to change it or drop it | | `^V` | Attach the image on the clipboard — the picture, or the one it only names | | `⌫` | On an empty composer, take the last attached image back off | | `^P` | Pick a model. Mid-turn too — the running agent is told | | `^E` | Every option this model has, on one panel, applied as you move | | `⇧⇥` | Permission mode. Belongs to this conversation and is saved with it | | `^T` | Projects and conversations | | `^J` | The computers in this workspace | | `^F` | The archive | | `^N` | New conversation. With another computer paired it asks which one first; in a repository it then asks where | | `^O` | Add a project — a path, another computer, or a repository address to clone | | `^B` | Toggle the sidebar | | `^K` | Command palette | | `^W` | Windows, panes and tabs — hold it to see what follows. See below | | `/` | Complete a command by name — neosh's, and whatever the agent accepts | | `^L` | What the plan has left, and where the week went | | `^G` | Git status | | `^D` | Show what changed | | `^S` | Read the transcript | | `⌥Y` | Copy this conversation's directory | | `^A` `^X` | Select everything, cut the selection | | `^C` | Copy the selection, or clear the draft, or (twice) quit | | `PgUp` `PgDn` `^End` | Scroll, and back to the newest message | | `^R` | Reload configuration | | `^Q` | Close this terminal. Whatever is running keeps running | | `^Z` | Every binding, live | Dragging an image onto the terminal pastes its path, and a pasted path to an image is attached rather than typed out. **Composer editing** is a text field: `←`/`→` by character and `^←`/`^→` by word, `Home`/`End` and `^Home`/`^End` for the ends, shift with any of them to select, `^⌫` and `^U` to delete a word or back to the start of the line. `^W` was the word-delete and is now the window prefix — a key cannot both delete a word and wait to see whether a window verb follows it, because the waiting is what makes every word-delete feel late. `ui.keys.delete_word` and `ui.keys.window_prefix` swap them back if you would rather. `model.upgrade` and `model.downgrade` ship with **no key** — they had `⌥↑`/`⌥↓`, which is not a key every terminal sends. `^K` runs both by name, and `init.ts` can bind them. ## Windows, panes and tabs — `^W` The one prefix that ships. It earns it: twenty-four verbs, and no Ctrl-with-a-letter left to give them — so most would otherwise arrive with no key at all. **Hold `^W` and wait.** A panel lists everything that follows it, read out of your own keymap rather than out of this page — so a rebinding shows *your* letter, and a plugin that adds a verb under the prefix appears in it with nobody writing glue. `ui.keys.hint_delay` is how long the pause is (300 ms; `0` shows it at once), and `ui.keys.window_prefix` moves the prefix. | Key | Does | | --- | --- | | `v` `s` | Split: the new pane on the right, or below. Focus follows | | `c` | Split to the right **on a new conversation** — splitting and `^N` in one key | | `q` | Close this pane. The last one is refused — `^Q` closes the terminal | | `o` | Close every pane but this one | | `h` `j` `k` `l` | Go to the pane that way. No wrap-around: the edge is where it stops | | `w` | The next pane, or the next **tab** when there is only one pane — so it always goes somewhere | | `<` `>` | Narrower, wider — Vim's, and the same keys the project panel resizes with | | `-` `+` | Shorter, taller. At an edge the other boundary moves rather than nothing happening | | `H` `J` `K` `L` | **Move this pane** to that edge — Vim's, and the counterpart to `hjkl` moving *you* | | `x` | Swap this pane with the next, keeping their places | | `=` | Give every pane an equal share | | `t` | A tab. **Asks what goes in it**: a new conversation, a shell, or this conversation again | | `T` | A tab with a **shell**, skipping the question | | `S` | Split into a **shell** | | `X` | Close this tab and everything in it | | `n` `p` | The next / previous tab, wrapping | | `1`–`9` | That tab, counting from the left as the bar numbers them | | `r` | Name this tab. `↵` on an empty name gives it back to being named by what is in it | | `,` `.` | Move this tab along the bar | | `?` | The list, now rather than after the pause | **Every pane is a whole conversation surface** — its own transcript, its own message field, its own draft, scroll, search and reading position. Two chats side by side are two of all of that; typing in one does nothing to the other. A split starts on what you were already reading, which is what `:split` means in Vim and is the only version that needs no dialog; `^T` and `^N` then change what is in the pane you are in. **A pane can be a shell.** `^WT` for a tab with one, `^WS` to split into one. Every key goes to the program inside — `^C` interrupts *it* — except the window prefix, which is always your way out. It runs `$SHELL -l` in the conversation's directory. **The strip along the top is always there.** Tabs on the left, numbered as `^W1`…`^W9` counts them, and the keys for wherever you are on the right. The pane with the keyboard is the one whose **edges are lit**. ## Reading the transcript — `^S` `^S` puts the editor in `Normal`, so chat's bindings stop firing and these get their keys back. Only `^C`, `^Q`, `^R` and `^S` are bound in every mode. | Key | Does | | --- | --- | | `hjkl`, arrows | Move. A count first does it that many times: `5j` | | `w` `b` `e` `ge` | By word. `W` `B` `E` by WORD — everything that is not a space | | `0` `^` `$` `g_` | Line start, first non-blank, last character, last non-blank | | `f` `F` `t` `T` then `;` `,` | To a character on this line, or just before it | | `%` | The bracket matching the one under the cursor | | `gg` `G` | Ends of the transcript. With a count it is a row: `12G` | | `^D` `^U` | Half a screen | | `^F` `^B`, `PgUp` `PgDn` | A screen | | `^E` `^Y` | Scroll a line without moving the cursor | | `H` `M` `L` | The top, middle and bottom row of the window | | `zz` `zt` `zb` | Put the cursor's line in the middle, at the top, at the bottom | | `[` `]` | Previous / next **turn** | | `c` `C` | Next / previous **tool call**. The card you land on opens itself | | `{` `}` | Previous / next **block** | | `⇥` `za` | *Keep* the tool card under the cursor open | | `/` `?` then `n` `N` | Search, and step through the matches | | `*` `#` | Search for the word under the cursor | | `v` `V` | Start a selection; `V` by whole lines | | `o` | While selecting: the other end of what you have | | `gv` | The selection you last gave up, back where it was | | `iw` `aw` `ip` `ap` `i"` `i(` … | Text objects, while selecting. `a` takes the delimiters | | `y` | Copy the selection, and leave | | `yy` `Y` | Copy the line | | `y` + a motion | Copy what it covers: `yw`, `y$`, `y5j`, `yG` | | `yi` + an object | Copy one without selecting it first — `yiw`, `yi"` | | `yc` | Copy the **code block** the cursor is in, without its indent or language line | | `ym` | Copy the whole **turn** — the question and everything it produced | | `yp` | Copy this conversation's **directory** | | `ya` | Copy the entire transcript | | `i` `a` `o` `⏎` | Back to the composer | | `^S` | And back out, the way you came in | | `Esc` | One thing per press: the selection, then the search highlight, then the mode | There is no `^V`: a blockwise selection is a rectangle rather than a range. Nothing here edits — no `d`, `x` or `p` — because the transcript is an artefact you take pieces out of. The card you are standing on is open, up to `chat.preview_lines`. Reading the last row keeps reading it: a turn still running writes below you and carries you along. Anywhere else you stay put, and `G` starts following again. ## Answering a question What an agent gets when it asks *you* something. Every key here is bound against the `neosh.question` buffer kind, so `^Z` lists it and `init.ts` can move it. | Key | Does | | --- | --- | | `↵` | Take the option under the cursor. With something typed, send that instead | | `1`–`9` | Take that option, while nothing has been typed | | `⇥` | Tick or untick, on a question that takes more than one | | `^P` `^N`, `↑` `↓` | Move | | `⇧⇥` | Back to the previous question | | type | Your own answer. `⌫` back to the options, `^W` a word, `^U` all of it | | `Esc` | Dismiss. The agent is told nobody answered, which it can act on | ## The project panel — `^T` | Key | Does | | --- | --- | | `↵` | Open a conversation, fold a project, or add one from the `+` row | | `j` `k` | Move. With a count, that many rows: `5j` | | `^D` `^U` | Half a screen; `PgUp`/`PgDn` for a whole one | | `gg` `G` | The ends of the list, and `5gg` / `5G` for the fifth row | | `>` `<` `=` | Wider, narrower, back to the default — it is the `sidebar.width` setting | | `n` | New conversation in the project under the cursor | | `o` | Add a project | | `f` | Pin a project to the top | | `J` `K` | Reorder within a group | | `r` | Rename a conversation | | `y` | Copy the row's directory | | `t` | A **terminal** in this project, in a tab of its own — on this computer, or on the machine the row is on | | `c` `C` | Connect to that computer, or disconnect from it. Only on a row about another machine | | `p` | Pull that repository (a git-plugin contribution) | | `d` | Remove a worktree from disk. It asks first (a git-plugin contribution) | | `x` `X` | Archive, delete. Deleting the last conversation in a worktree removes the checkout too, and says so. On a repository's heading, `X` takes the project off the list; on a worktree's, it removes the checkout | | `a` | The archive | | `⇥` | On the plan rows: how much of it to show | | `?` | The keys for whatever row you are on | | `Esc` | Back to the composer | ## The archive — `^F`, or `a` in the project panel Rows can be ticked, and every verb means what is ticked or — with nothing ticked — the row under the cursor. | Key | Does | | --- | --- | | `↵` | Put this one back and go to it | | `u` | Put these back, and stay here | | `X` | Delete these, for good. It asks, and says how many | | `^X` | Empty what the panel is **currently showing**, so a filter first narrows it | | `` `⇥` | Tick this row, and step down | | `a` | Tick everything showing, or untick it | | `e` | Copy these conversations to the clipboard, as markdown | | `y` | Copy this conversation's directory | | `/` | Filter — on the title, the project and the path | | `s` `S` | The next ordering along; the next grouping along | | `j` `k`, `^D` `^U`, `gg` `G` | Move, with counts | | `?` | The keys | | `Esc` | One thing per press: the marks, then the filter, then the panel | | `^F` `q` `^C` | Close it | ## A panel that does not fit Every read-only panel in the workspace — the key sheet, git status, a diff, another machine's conversation — is given whatever height the screen has. When that is less than it wants to draw, the panel **scrolls**, a bar appears down its right border to say there is more, and the mouse wheel scrolls whatever the keyboard is in rather than the transcript underneath it. | Key | Does | | --- | --- | | `j` `k`, `↓` `↑` | A line | | `^D` `^U` | Half a screen | | `^F` `^B`, `PgDn` `PgUp` | A screen, less a row of overlap | | `gg` `G`, `Home` `End` | The ends | | `Esc` `q` `↵` `^C` | Close | They are ordinary bindings on the `neosh.scroll` buffer kind, so `^Z` lists them and `init.ts` moves them: ```ts await neosh.keymap.set("normal", "", "scroll.down", { scope: { kind: "buf_kind", name: "neosh.scroll" }, }); ``` A panel with a cursor of its own — a picker, the project panel, the usage panel — is not one of these: it moves its cursor and the view follows, which is why `j` there means "the next row" and not "one line down". ## Pickers | Key | Does | | --- | --- | | `↵` | Choose | | `^N`/`^P`, `^J`/`^K`, `↑`/`↓` | Move | | type | Filter | | `⇥` `⇧⇥`, `→` `←` | Between the two panes of the model picker | | `Esc`, `^C` | Close | In the model picker, chords — because every bare letter a picker takes is a letter its filter can never contain: | Key | Does | | --- | --- | | `^S` | Sign in to the provider the rail is on | | `^R` | Ask that endpoint what it serves again | | `^A` | Add a model by id | | `^D` | Remove one you added | These are settings (`ui.keys.next`, `ui.keys.accept`, …), so rebinding them changes what the pickers say they do. ## Rebinding ```ts await neosh.keymap.set("chat", "", "chat.clear"); await neosh.keymap.del("chat", ""); ``` Keys bind to command names, never to callbacks — which is what makes every binding listable with `^Z`, runnable by name from `^K`, and replaceable by you or by another plugin. [Keymaps](/docs/keymaps) covers modes, scopes, `` and sequences. ==================== # Docs for agents URL: https://neoswarm.dev/docs/agents ==================== ## Two skills, for two different jobs **Writing plugins** — everything below. The agent learns the API, the sandbox and how to verify its work. **Driving neosh** — running conversations in it, fanning work out across worktrees, collecting the results. That one ships in the binary: ```sh neosh skill install ``` It writes into `.claude/skills/`, `.agents/skills/` (Codex and the shared Agent Skills location), `.cursor/skills/` and `.gemini/skills/`; `--project` scopes it to a repository, `--agent` picks one. [Scripting](/docs/scripting) is the human version. Installing both is normal — they never overlap. ## The fastest path Using Claude Code? Install the neosh skill once and you are done. The agent loads it automatically whenever a task is about neosh: ```sh mkdir -p ~/.claude/skills/neosh-plugins && curl -fsSL https://neoswarm.dev/skill.md -o ~/.claude/skills/neosh-plugins/SKILL.md ``` Then just ask for what you want: ``` Write me a neosh plugin that shows my open PRs in the sidebar. ``` That is the whole setup. The skill tells the agent where the full API lives, what the sandbox does and does not allow, how to type-check against your exact binary, and how to verify its work in a running workspace. ## Other agents For Cursor, Codex, or anything that reads an `AGENTS.md`, append the same content (it is the skill without the frontmatter) to your project's file: ```sh curl -fsSL https://neoswarm.dev/agents.md >> AGENTS.md ``` For a project-scoped skill instead of a global one, put it in the repository: ```sh mkdir -p .claude/skills/neosh-plugins && curl -fsSL https://neoswarm.dev/skill.md -o .claude/skills/neosh-plugins/SKILL.md ``` ## No setup at all One prompt works without installing anything: ``` Read https://neoswarm.dev/llms-full.txt, then write a neosh plugin that . Put it in ~/.config/neosh/plugins// with a plugin.toml and a main.ts, declare only the permissions it needs, and type-check it with `cd ~/.config/neosh && npx tsc --noEmit`. ``` ## What the agent gets Every URL is regenerated from the shipped code on each site build, so none of them can drift: | URL | What it is | | --- | --- | | [/skill.md](/skill.md) | The plugin-development skill: constraints, workflow, API map, conventions | | [/agents.md](/agents.md) | The same content, ready to append to an AGENTS.md or rules file | | [/llms-full.txt](/llms-full.txt) | The whole corpus: every docs page, the canonical repo guides, and the complete `@neosh/api` TypeScript source | | [/llms.txt](/llms.txt) | The index, in the llms.txt convention | | `/docs/.md` | Any single docs page as markdown, also behind the **Copy page** button above | Two more things on the user's machine make the loop tight: `neosh init` writes the exact API types for the installed binary to `~/.config/neosh/types/`, and a plugin dropped in `~/.config/neosh/plugins/` is live on every save with `^R` to reload. ## neosh as the harness The other direction also holds: neosh is itself the best place to run the agent that writes the plugin. Open a conversation in the plugin's directory, and the agent's file tools resolve there, `^R` reloads its work, and `^Z` shows whether its binding actually registered. An agent driving its own host is the fastest feedback loop there is. ==================== # Canonical guide: configuring neosh (docs/config.md) ==================== # Configuring neosh ```sh neosh init ``` That writes a starter config, a `tsconfig.json`, and the API types — then tells you the path. Open `init.ts` and start editing. --- ## The one thing to understand **Your config is a plugin.** `init.ts` gets the entire public API, the same one every plugin uses, with nothing held back. There is no config-only API and nothing a plugin can do that your config cannot. This is why there is no list of "supported config options" anywhere in this document. Anything in [the API](../plugins/api/src/index.ts) is available to you — see [plugins.md](plugins.md). ## Where things live ``` ~/.config/neosh/ $XDG_CONFIG_HOME, or $NEOSH_CONFIG_DIR ├── init.ts your config — this is the important file ├── config.toml values only; the non-code path ├── tsconfig.json generated, so your editor knows the API ├── types/ generated, the API types for this exact binary └── plugins// drop a plugin here and it loads plugin.toml + its entry module ~/.local/share/neosh/ installed plugins (a manager's business, not yours) ~/.local/state/neosh/ trust.json — projects you allowed to run code /.neosh/ project config; see "Per-project" below ├── config.toml └── init.ts ``` Same roots Neovim uses, for the same reason — `~/.config/neosh/init.ts` is to neosh what `~/.config/nvim/init.lua` is to Neovim, and symlinking the config directory into a dotfiles repo works exactly as you would expect. ```sh neosh paths # what those resolve to here, and which of them exist ``` Overridable with `NEOSH_CONFIG_DIR`, `NEOSH_DATA_DIR`, `NEOSH_STATE_DIR`, `NEOSH_CACHE_DIR`, or `--config-dir`. `neosh --clean` reads and writes nothing at all — use it when reporting a bug. ## init.ts ```ts import type { PluginContext } from "@neosh/api"; export async function activate({ neosh }: PluginContext) { await neosh.opt.set("agent.model", "claude-cli/sonnet"); await neosh.cmd.register("chat.clear", async () => { /* … */ }, { desc: "Clear the conversation", }); await neosh.keymap.set("chat", "", "chat.clear"); } ``` Keys bind to **command names**, never to callbacks. That indirection is what makes every binding listable, remappable, and callable by other plugins. Reload with **``** — no restart. It re-reads every layer from disk, unloads every plugin, and resets settings back to their defaults first, so deleting a line actually removes its effect. If the new config does not parse, the running one is left alone and you are told why. Type checking, and testing what you wrote — see **[testing.md](testing.md)**: ```sh cd ~/.config/neosh && npx tsc --noEmit ``` The types in `types/` were written by the neosh binary you are running and are refreshed at startup if they drift, so they always describe the version you actually have. **If `init.ts` throws, neosh still starts.** The error is reported and startup continues — you need the editor to fix the editor. ### Built-in commands The host registers its own commands through the same registry your plugins use, so they are listable with `neosh.cmd.list()` and rebindable like anything else: | command | default key | | |---|---|---| | `config.reload` | `` | re-read config and reload plugins | | `quit` | `` | close neosh | Both are shown in the status line at the bottom of the screen, which also reports the current mode and model. Binding the same key in your config replaces the default — these are ordinary bindings, not reserved keys. ## The bundled plugins The sidebar, the model switcher and the git actions ship inside the binary. They are **plugins**, not features: same manifest, same API, same lifecycle, and nothing in their source that yours could not do. They exist partly to make that claim testable — `scripts/check.sh` type-checks them against the published `@neosh/api`, so the moment one reaches for something not public, the build fails. | Plugin | What it does | Keys | |---|---|---| | `sidebar` | Projects, conversations, changes, model, usage | `` hide, `` thread list, `` new | | `model` | Model and reasoning-effort switchers, and the footer | ``, `` | | `git` | Status, branches, commits, diffs, worktrees | ``, `` | | `palette` | Everything by name, with its binding | ``, `` | | `approvals` | Asks before the agent does something gated | — | Press `` for the complete list; it is read from the registry, so it is never out of date. Turn one off: ```toml [options] "plugins.disabled" = ["sidebar"] ``` Or replace it: drop a plugin of your own in `~/.config/neosh/plugins/`. Yours loads *after* the bundled ones, so registering the same command or key wins. `neosh --clean` loads none of them, which is what makes it useful for answering "is this neosh or is it my setup". ### Conversations and projects The sidebar groups conversations by the directory they were opened in. There is no project registry to maintain: opening a conversation somewhere else *is* how a second project appears, and `git.worktree.list` opens one in another worktree. **A conversation's directory is where its work happens**, not just where it is filed. Switching conversation moves four things with it: the repository `neosh.git` answers about, the root the agent's file tools resolve paths against, the directory the welcome block names, and — the one that matters most — the directory the model's own agent is started in. A vendor CLI reads files, runs commands and looks at git relative to where it was spawned, so a driver launched in whatever directory neosh happened to be started from is answering about the wrong repository. Drivers are told per turn, through `TurnRequest.cwd`. ### Worktrees A worktree is a second checkout of the same repository on a different branch, and it is the answer to "I want to try something without disturbing what is checked out". neosh treats one as a *project*: it has its own conversations and its own branch in the footer, and in the sidebar it sits **inside the repository it is a tree of** — the repository is the row, its worktrees nest under it by branch, and each folds, reorders and starts conversations on its own. Run as many conversations in one worktree as you like; `n` on its row is how a second one starts there. `^N` asks where a new conversation goes when there is something to ask: ``` New conversation > ❯ ● Here /home/you/work/project + In a new worktree a clean branch, named for you, nothing to answer ⌂ In a new worktree, in this project kept in .worktrees/ — travels with the repository + In a new worktree, named… a branch of its own, checked out somewhere else ⎇ fix/thing worktree · ~/.nsh/project/fix-thing → api on studio · /home/you/work/api … Another directory… somewhere else entirely ``` The in-project row is `git.worktree.new.inside` — a branch named for you, kept at `/.worktrees/` (or wherever a relative `worktree.root` points) with the exclude entry written, no configuration needed. It is the same thing a relative `worktree.root` makes `In a new worktree` do; the row exists so the choice is visible before you have configured anything. `Here` is selected, so `^N ⏎` is what `^N` always did. Outside a git repository the question has one answer and is not asked at all — `^N` stays a single key everywhere the choice would be theatre. `session.new.here` skips it unconditionally, for a key of your own. **`n` in the project panel asks the same question**, about the project the cursor is on rather than the conversation you are in. It used to create one outright, and that was the bug: one letter and a modifier apart, doing visibly different things. `Here` is still the first row, so `n ⏎` is what `n` always did — and because the question is about the row and not about you, the worktrees offered are that repository's. The second row is the one you want most days. **A worktree with nothing to answer**: the branch is named for you — two words and a tag, `brisk-otter-k3f9`, a thing you can say out loud and find again in a list of eight of them, on a name no other tree of yours has ever had — it lands under `worktree.root`, and you are in it. Naming a branch before you know what the work is is a decision made at the worst possible moment, and having to make it is what stops people reaching for a clean tree at all. Renaming it later is `git branch -m`, like any other branch. It is `git.worktree.new.auto` for a key or a script. The row below it is the same thing when you *do* know the name, and it asks exactly one question — the branch. The location is a setting, and asking again would be asking somebody to repeat themselves; the message says where it landed. `git.worktree.new [path] [cwd]` takes an explicit path for the times you want one, and a repository for the times it is not the one you are in. A worktree is listed by the branch it is on, under the repository it belongs to, rather than by its directory name. The directory is named by whoever created it, and a panel of those says nothing about which checkout is which. New worktrees go under `worktree.root`, laid out as `//`: ```toml [options] "worktree.root" = "~/.nsh" # the default. `~` expands. ``` A directory of neosh's own rather than a sibling of the repository, because a worktree is not part of the project you are working on and littering its parent with `project-worktrees/` is how people end up with checkouts they cannot account for. The repository name is a level of its own so two projects with a `main` branch do not collide, and a slash in a branch becomes a dash — `feat/thing` is one directory, not two, because the directory is a name and not a path. A **relative** root keeps the trees inside the repository itself: ```toml [options] "worktree.root" = ".worktrees" # /.worktrees/ ``` No `` level there — inside the repository, nothing else's worktrees can collide with yours — and when neosh creates the tree it writes the directory into the repository's **`.gitignore`** (`/.worktrees/`, one line for every tree after it), so an in-repo checkout never sits in `git status` as one giant untracked directory. The tracked file rather than `.git/info/exclude`, because the exclude file is per clone: a colleague pulling this layout would rediscover the noise. Nothing is written when the path is already ignored — by that line, a rule of your own, or a global ignore. Setting it to `""` restores the sibling layout, for anyone who wants their trees next to the thing they are trees of. The rest of a worktree's life is on its sidebar row: `y` copies its path for the shell you are about to `cd` in, `p` pulls its repository from the remote and says what git said, and `d` removes the checkout from disk — the branch stays, it asks first, and it tells you how many conversations go with it. `⌥Y` in the chat and `yp` in the reader (`^S`) copy the *current* conversation's directory, which in a worktree is the worktree; `session.copy.path` is the same thing by name from `^K` or `/copy`. `p` and `d` are contributions from the git plugin (`sidebar.action`), so they follow it when it is disabled, and a sidebar of your own inherits them for free. Conversations are saved as you go — one file each under `~/.local/state/neosh/sessions/` — and restored at startup, in the one you were last in. `--clean` neither reads nor writes them. Pin the projects you live in with `f` and they move to the top of the list, marked with a heart; `J`/`K` reorder within a group, and a pinned project stays listed after its last conversation is gone, so `Enter` on it starts a new one. The arrangement is saved under `~/.local/state/neosh/plugin-state/` — not in your config, because an editor that rewrites the file you hand-edited because you pressed a key is one you stop trusting with it. While a turn is running the row shows how long it has been running, next to the spinner. "Working" with no clock is indistinguishable from wedged. In the panel (``): `↑`/`↓`, `j`/`k` or `^N`/`^P` move, `Enter` opens or folds, `Space` folds, `f` pins, `J`/`K` reorder, `n` new conversation here, `x` archive, `X` delete, `a` the archive, `r` rename, `?` every binding, `Esc` leaves. `J`/`K` work from a conversation row too — they move the project it is in, because you are looking at the project when you are looking at what is inside it. `>` and `<` make the column wider and narrower and `=` puts it back — they are the `sidebar.width` setting, so this key and `config.toml` say the same number, and the panel's own heading says `<> width` while it has the keyboard. The foot of the panel lists the keys for whatever the cursor is on; set `sidebar.hints = false` once they are in your fingers. ### Archiving, and the one verb that deletes `x` archives. Everything is kept — every message, the file on disk — it simply leaves the panel. Not into a section at the foot of it: **archived conversations are not rows in the sidebar at all.** What is left behind is one row, `┈ Archived` with a count, and only while there is something behind it. It asks nothing, because it takes nothing away. Charging a confirmation for a reversible action is what teaches you to dismiss confirmations, which is how the one that matters stops working. `a` in the panel, or `` from anywhere, opens what you have put away as a list you can filter — each row with the project it came from, how many messages it holds and when it went. `↵` restores one and switches to it, `^U` puts it back without going there, and `^X` deletes it. `X` deletes: the file goes and there is no undo, so it always asks — including for a conversation with nothing in it yet, because a key that stops and asks only sometimes is a key you cannot predict. The dialog says how much is at stake and which project it is in, `y` and `n` answer it outright, and `Esc` means no. `ui.confirm_destructive = false` turns off every one of these, everywhere. Archiving the conversation you are in moves you to the most recently used other one, or starts a fresh one if there is no other. For plugins: `session.archive(id)`, `session.archive(id, false)`, and `session.list({ includeArchived: true })`. Plain `list()` leaves them out. ```toml [options] "sidebar.open" = true # show it at startup "sidebar.width" = 34 "sidebar.hints" = true # the contextual key strip at the foot of the panel "sidebar.refresh_ms" = 4000 # a workspace re-read per tick ``` ### Keys are yours Every binding in neosh — including the ones that ship — is an ordinary entry in the same table your config writes to, bound to a *command name* rather than a callback. Setting the same key replaces what was there. ```ts await neosh.opt.set("mapleader", ","); // set it before you use it await neosh.keymap.set("chat", "pa", "project.open"); await neosh.keymap.set("chat", "pp", "sidebar.focus"); await neosh.keymap.del("chat", ""); // and drop the default if you want ``` `` is substituted when the binding is made, not when the key is pressed — the same as Neovim, and for the same reason: a binding that silently moved when you changed `mapleader` halfway down your config would be impossible to reason about. Nothing ships bound to ``, which is deliberate: the default leader is `\`, and a leader with bindings on it is a character you can no longer type immediately. When you do bind a sequence, a prefix you start and do not finish is typed literally after `timeoutlen` milliseconds — so `,pa` does not cost you the comma key. ```toml [options] mapleader = "," timeoutlen = 500 # how long to wait for the rest of a sequence ``` `^K` searches every command by name, and `^Z` lists every binding — both read the live registry, so a plugin loaded five minutes ago is in them without anything having been written down. Every picker carries a strip along its foot saying what it answers to, written from the bindings rather than from a string, so rebinding `ui.keys.*` changes what the strip says rather than making it a lie. `AGENTS.md` at the root of the repository has the whole key table in one place. ### Every default is a key your terminal already sends A key neosh never receives is a key no amount of rebinding will fix, so nothing ships bound to one that depends on a setting you have to go and find. In practice that rules out three families: - **Function keys.** Apple's top row is brightness and volume until somebody turns on *Use F1, F2, etc. as standard function keys*, so `F1` — the key that listed every key — was the one key a new Mac could not press. The key list is `^Z`. - **Option / Alt.** macOS treats Option as a compose key: `⌥p` is `π`, not `Alt+p`, unless the terminal has been told otherwise. The model ladder was `⌥↑`/`⌥↓` and now has no default key at all; taking an attached image back off was `⌥V` and is now `⌫` on an empty composer. The one Alt chord that ships is `⌥Y`, and it ships on the terms arrows do: copying the conversation's directory is also `yp` in the reader, `y` in the project panel and `session.copy.path` from `^K`, so a terminal that sends `¥` has lost nothing. - **Ctrl with punctuation.** In a plain terminal `Ctrl+/` and `Ctrl+7` are the same byte. Neither is a key worth binding — see enhanced keys, below. What is left is Ctrl-with-a-letter, `⇧⇥`, `⏎`, `⌫` and `Esc`, which every terminal on every platform sends the same way, plus the keys a keyboard may or may not have — arrows, `PageUp`, `Home`, `End`. Those last ones are bound wherever they make sense and are never the *only* way to do anything: every list moves on `^N`/`^P` as well as the arrows, and the hint strip prints the chord, because a legend is a promise about a keyboard. If you have the keys, use them, and if you want the ones that were dropped, they are one line each: ```ts await neosh.keymap.set("chat", "", "help.keys"); await neosh.keymap.set("chat", "", "model.upgrade"); await neosh.keymap.set("chat", "", "model.downgrade"); await neosh.keymap.set("chat", "", "chat.image.drop"); ``` **Enhanced keys, where the terminal has them.** On startup neosh asks the terminal for the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) — supported by kitty, Ghostty, WezTerm, foot, rio, Alacritty, iTerm2 and Windows Terminal — and quietly does without on terminals that lack it. It buys three things that are otherwise impossible rather than awkward: - **`⌘` arrives at all.** Without it `Super` is never sent, so a `` binding is accepted, listed, shown in the footer and never fires. - **`Ctrl` with punctuation is distinguishable.** In a plain terminal `Ctrl+/` and `Ctrl+7` are the same byte, which is why neither is a key worth binding. - **`Esc` is unambiguous**, so nothing waits to find out whether more of a sequence is coming. `NEOSH_NO_ENHANCED_KEYS=1` turns the request off. It is an environment variable rather than an option because the terminal has to be in the right mode before the first keystroke, which is before there is any config — and because the symptom of a terminal that answers this question wrongly is one you cannot fix from inside a program you can no longer type into. ### The other computers — `[swarm]` Several machines, one workspace: every one knows what the others are running, and can ask them to do something. **On by default**: the identity exists, paired machines are dialled, and `0.0.0.0:7717` is listening, so adding this computer from another one works before anything is configured. What stays consent is *membership* — a machine not on the allow-list gets nowhere regardless of what it can reach, and joining still takes a yes on both machines. ```toml [swarm] enabled = false # the whole switch: no identity, no listener, no dialling listen = "" # or just dial-only: joins machines without being joinable ``` The default port being taken — a second workspace on the same machine — downgrades to dial-only with a line in the log, never a workspace that refuses to start. `This computer` in `^J` says which happened: `listening on 0.0.0.0:7717`, `not listening`, or `dial-only`. #### What a connection is doing Every paired machine is a row in `^J` from the first frame, and the row says where the link stands. The glyph is the state and the sentence is what to do about it: | Row | Means | |---|---| | `●` and a conversation count | Up | | a spinner, `connecting…` | Being dialled, never yet answered. `try 4` once it has failed a few times, because a spinner that has been spinning since Tuesday is not a state | | a spinner, `reconnecting` | Was here, dropped, being dialled again | | `◍ allow this computer on linux-box — ^J there` | Reached it, and **it has not allowed this machine yet**. The second half of pairing, waiting on a person at the other end | | `○ disconnected` | Nothing is dialling it — it dials in, or you pressed `^D` | The spinner only ever means *this* machine is working on it. A row waiting on somebody at the other end does not move, because nothing here is trying: it pulses, like every other thing in the workspace that is waiting for you. Reconnects back off from a second up to half a minute and reset the moment a dial lands, so a machine that reboots is back in seconds. A half-paired row retries at the flat heartbeat instead, which is what makes it turn over into a connection within seconds of somebody saying yes over there — no key, no restart. The verbs on a row, and the list updates live while it is open: | Key | Does | |---|---| | `^E` | Call it something else. An SSH `Host` alias: it wins over the name the machine announces, everywhere, and clearing the field gives the hostname back. Refused for a machine your `config.toml` declared, whose `name` is one line away | | `^R` | Dial it again now, rather than waiting out the retry delay | | `^D` | Disconnect: close the link and stop dialling, keeping the pairing. `^R` takes it back | | `^X` | Unpair: forget it altogether | | `^Y` | Copy this computer's own id | Renaming earns its key because a hostname is chosen by whoever set the machine up: two Macs out of the box are both `Mac`, and a list of machines that are all called the same thing is a list you cannot use. What you call one is written to the state directory beside the pairing, so it survives a restart and every panel agrees about it. Disconnect holds in both directions — a peer that dials in is turned away until you say otherwise — and lasts until a reconnect or a restart. Unpairing (`^X`) is the stronger verb, for a machine you are done with rather than done with for now. #### Adding a computer `^J` on either machine. Pick **Add a computer…**, type its address, and you are shown what is actually there: ``` Add linux-box? 127.0.0.1:7739 · macos · neosh 0.1.0 1ca3 c856 8f4c 6584 Check that fingerprint matches what that computer shows under `This computer`. ``` The name and the fingerprint are read off the far machine rather than typed by you, which is the point: a public key typed from memory is a public key typed wrong, and a fingerprint is only worth showing if it came from the other end. Then the other machine says `mac-studio wants to join this workspace — ^J to allow it`, and somebody there says yes. Two confirmations, one per machine, because authorising a computer to steer your agents is a decision each side gets to make. Between the two, you are put back in `^J` with the new machine sitting on `allow this computer on linux-box — ^J there`. That is the panel to leave open while you walk over: the row turns into its conversation count on its own, within a heartbeat of the yes, with nothing to press here. Nothing is restarted and nothing is written to your `config.toml` — paired machines live in the state directory. `^X` in that list removes one. #### Or by hand `neosh swarm-id` prints this machine's identity and the lines to paste elsewhere. A node is named by its public key, so joining two machines this way means each having the other's: ```toml [swarm] name = "mac-studio" # what the others call you; the hostname if unset listen = "100.71.4.9:7717" # omit for 0.0.0.0:7717; "" to be dial-only [[swarm.peers]] addr = "linux-box:7717" id = "453ff38823de6515…" # from `neosh swarm-id` over there name = "linux-box" ``` `id` is the authorisation. An address says where to look; the key says who it is, and a machine that cannot prove that key is refused however it reached you. A peer entry without one can dial and will be turned away — a louder failure than trusting whoever answers. **Networking is not ASCP's job.** On a LAN it needs nothing. Across the internet, run [Tailscale](https://tailscale.com), Nebula, NetBird or ZeroTier underneath and use whatever address that gives you. Hole-punching is a hard problem those projects have solved properly, and an agent protocol having its own go at it would be doing their job instead of its own. Two more settings, and they are different kinds of trust: ```toml accepts_commands = true # may peers steer agents here at all accepts_approvals = false # may peers answer permission prompts here ``` Steering an agent is a message. Approving one is a **write to this machine's disk**, which is why it is separate and off by default. A build machine that should be watched and not touched sets `accepts_commands = false` and is visible, read-only, to everyone. Changing `listen`, `name` or the settings above takes a restart rather than `^R`: the listening socket is decided at boot, and re-reading it would mean tearing down live connections whenever you reloaded your config. *Pairing* is the exception and takes effect immediately — a swarm you have to restart to add a computer to is a swarm you add one computer to. A `[[swarm.peers]]` entry you wrote by hand cannot be removed with `^X`: that line would come back on the next reload, and a removal that silently undoes itself is worse than one that says why it cannot. #### What you see Conversations on other machines appear in the sidebar **under the same project as yours**, dimmed, with the host they run on at the end of the row. A project row says which other computers have it. Projects are matched by normalised git remote, not by path — `/Users/me/dev/neosh` here and `/home/me/src/neosh` there are one project, and it does not matter that one was cloned over SSH and the other over HTTPS. A directory with no remote falls back to its name, which is a guess and shown as one. `^N` and `^O` offer the other machines alongside your own worktrees, so "start something over there" is one key — and the field completes paths on whichever machine you point it at. See [Adding a project](#adding-a-project). What a machine advertises is the list *its* panel shows, refreshed as it changes rather than fixed at the moment you connected — so a project added over there appears here within a couple of seconds, and one you cleared out but did not remove is still somewhere you can start something. `↵` on a remote conversation **opens it**: its history, then everything as it happens. `i` says something to it and `^C` asks its turn to stop — because "it feels like it is on this computer" is a claim about what you can do, not only about what you can see. It is a window rather than the chat pane, deliberately: the chat pane is *your* conversation, and putting somebody else's there would make "where does my composer send" a question with two answers. The protocol is specified in [ascp/SPEC.md](ascp/SPEC.md). ### Adding a project `^O` anywhere, `o` in the project panel, or `Enter` on the `+ Add project` row. It is the `project.open` command, so `^K` finds it and `pa` can be it. **The filter line is the path field.** There is no row to reach first and no mode to enter: start typing `/`, `~` or `./` and the list under it becomes the directories that match. `` takes the highlighted one into the field so you can keep descending, `` goes back up a whole segment rather than a character, and `` accepts either the highlighted row or exactly what you typed — because the directory you want may not be one it offered. `~` expands, and a path with no `/` completes against the conversation's own directory. Underneath: `Type a path…`, which is the row that says the field takes one, and every computer you have paired. Nothing else. It used to list the worktrees of the repository you are standing in, which was wrong twice over — the ones already on the panel were an offer to add what was added, and the ones not on it were the scratch branches you had removed, handed back. `^O` means *somewhere I do not work yet*, and a list of the repository you are already in cannot answer that. `^N` is where this repository's worktrees are offered, and there they are the ones on the panel's own list. #### From a repository address Paste one into the same field and the row becomes *clone it*: ``` https://github.com/owner/repo git@github.com:owner/repo.git ssh://git@host/owner/repo file:///srv/mirrors/repo owner/repo # github.com, spelled out on the row before anything is fetched ``` **It always asks where.** A clone writes a repository's whole history onto your disk, and that is not a thing to decide for you and mention afterwards. The first row is `clone.root` — `~/.nsh/repos` unless you set it, as `//` so two accounts' `api` do not collide — and `` takes it, so the asking costs a keystroke you were pressing anyway. Under it is everywhere you have cloned to before, and then `Somewhere else…`, which asks for a folder and puts `/` in it. **Whatever you pick through that is remembered** and is a row from then on, up to the six most recent. That is the whole of adding a location: there is no list to maintain and no setting to find. A destination that already exists is shown, greyed, and refused — the directory being there is very often the answer to "why is this repository not in my sidebar", and a row that quietly disappeared could not tell you. While it fetches, a progress row shows git's own phases with a meter where git gave a total and a spinner where it did not. Nothing takes the keyboard: the picker has closed and you can carry on. A failure says git's own last line — `Repository not found`, `Permission denied (publickey)`, `could not resolve host` — because each of those is a different thing to go and do. When it lands, the project opens. `^K git.clone ` is the same verb from the palette or a script, and answers with the directory. Private repositories work wherever `git clone` already does: neosh uses your git credential helpers and your SSH agent, and never prompts — a password prompt underneath a full-screen program is a hang with no visible cause, so an unauthenticated clone fails with git's message instead. #### On another computer Type `linux-box:` and the same field completes directories **over there** — scp's spelling, meaning what it means there. With nothing after the colon it lists what that machine offers: the projects it works in, filled where somebody has a conversation open and hollow where nobody has, with the counts beside them. Keep typing and it is an ordinary directory listing of that machine's disk, one segment at a time, `` and all. `` starts the conversation **there** — the agent runs on that machine, against that machine's files — and opens a window onto it here. Every paired machine is a row whether or not it can be used. One that has not allowed this computer yet, or that is disconnected, or that is read-only, says which; `` on it repeats the reason rather than opening an empty list. `^N` asks the same question with the same field, alongside `Here` and the worktree options. Listing a machine's directories follows `accepts_commands`: a machine that will start an agent anywhere you name has already given away more than the names of its directories, so there is no separate setting. Directory names only — never files, never contents. A machine running a neosh older than this says so rather than dropping the connection. ### Confirmations Anything that cannot be undone asks first: deleting a conversation removes its file from disk, and `git worktree remove` does not put a directory back. The cursor starts on the answer that changes nothing, because `` is reflex by the second time you have seen a dialog. It asks *conditionally*. A conversation you have not said anything in yet has nothing to lose, so `x` just closes it — a dialog for that is friction that teaches you to dismiss dialogs. Nothing that only changes what is on screen ever asks. ```toml [options] "ui.confirm_destructive" = true ``` ### Picker keys Every picker, prompt and completion list reads the same settings, and they take Neovim notation. More than one key can mean the same thing: ```toml [options] "ui.keys.next" = " " "ui.keys.prev" = " " "ui.keys.page_down" = "" "ui.keys.page_up" = "" "ui.keys.first" = "" "ui.keys.last" = "" "ui.keys.accept" = "" "ui.keys.dismiss" = " " "ui.keys.complete" = " " "ui.keys.clear" = "" "ui.keys.delete_word" = "" "ui.keys.window_prefix" = "" "ui.keys.hint_delay" = 300 ``` A widget claims these **window-scoped** while it is open, so they outrank global bindings and are released the moment it closes. That is what makes `^N` move in a picker even though `^N` is otherwise "new conversation" — without it, filtering a model list by typing `n` with control held would drop you into a new conversation with the picker gone. ### Writing, selecting and copying The composer is a text field, not a line that gets appended to. `←`/`→` move by character and `^←`/`^→` by word; `Home`/`End` reach the ends of the line and `^Home`/`^End` the ends of the draft. Hold shift with any of them to select. `S-CR` breaks the line instead of sending, so a pasted snippet stays one message. `^W` deletes the word behind the cursor and `^U` clears back to the start of the line. `^C` copies when something is selected and clears the draft when nothing is — the two cases cannot both be true, which is what lets one key carry both jobs. `^X` cuts, `^A` selects everything. Your terminal's own paste works: it arrives as a bracketed paste and lands at the cursor. ### Reading the transcript `^S` moves the keyboard into the transcript, which is where the text you actually want to keep lives. It is a mode, not a focus change, because the keys mean different things there. The status line says `reading` while you are in it, and the row under the composer says what the keys do — including, once an operator is down, what can follow it. The keys are vi's, chosen rather than invented: | | | |---|---| | `hjkl`, arrows | move | | `w` `b` | by word | | `0` `$` | ends of the line | | `gg` `G` | ends of the transcript | | `^D` `^U` | half a screen | | `^F` `^B`, `PgUp`/`PgDn` | a screen | | `zz` `zt` `zb` | put the cursor's line in the middle, at the top, at the bottom | | `[` `]` | previous / next **turn** | | `{` `}` | previous / next **block** | | `/` `?` | search forward / backward | | `n` `N` | next / previous match | | `v` | start a selection that motions extend | | `V` | select this whole line | | `y` | copy the selection, and leave | | `yy` `Y` | copy the line | | `yc` | copy the **code block** the cursor is in | | `ym` | copy the whole **turn** — the question and everything it produced | | `ya` | copy the entire transcript | | `i` `a` `o` `⏎` | back to the composer | | `Esc` | drop the search highlight, then leave | Two of those are not in any editor, because a transcript has two things a file does not. `[`/`]` step between turns, found from the bar drawn down the left of a question rather than from a remembered list — a remembered one would be wrong in exactly the case you need it, which is scrolling back through a long conversation. And `yc` takes the code block the cursor is in, without the indent the renderer added or the language line above it. That one is the reason the mode exists: an answer with a command in it is worth very little if getting the command means selecting it by hand across a wrapped line. Searching is incremental — hits light up as you type — and case-insensitive unless the query has a capital in it. The composer is borrowed to type into, and your draft comes back when the search closes, however it closes. Copying uses OSC 52, which travels back through the terminal connection — so it reaches the clipboard on the machine you are sitting at, not the one neosh is running on. Terminals that do not implement it ignore it silently; there is no way to detect support. ### The strip along the bottom ``` chat ask ⇧⇥ main ███░░░░░ 38% of 200k ↑12k ↓4k claude-opus-5 ^P High ^E ``` Left is what the conversation is *spending* — the permission mode, the branch, how full the context window is, tokens in and out. Right is what it is spending it *on*. They are separated because they change at different rates, and interleaving two things that move at different speeds makes both harder to read. The context meter is drawn from the moment a conversation opens, empty, rather than appearing once a turn has been spent. The one time you most want to know how much room a model has is *before* deciding what to do with it, and a 200k window and a 1M one are different tools. The bar is for the shape of the answer — plenty of room, getting full, nearly out — and the number beside it for the rest; the colour changes past 70% and again past 90%. When the strip is too narrow for everything in it, whole segments are dropped, least important first. Nothing is truncated: half a token count is a wrong token count, and a bar cut short reads as a level nothing is at. Every entry carries the key that changes it, immediately after it. That is the whole rule: a key is memorable once it has been seen beside the thing it does, and a legend somewhere else is a second place to look for something that is already on screen. It also means those keys are *not* repeated on the shortcut row below — saying it twice costs a row and teaches nothing the first place did not. There is no shortcut row below by default, for the same reason. It carried `^T`, `^N` and `^K`, which are in the sidebar's own footer two rows away, and `^Z`, which is on the row it points at. What the duplication actually bought was one fewer line of transcript and a composer pressed against the status strip. `ui.hints = true` brings it back if you want it; the exception is reading mode, which draws its own row whatever the setting says, because those keys are live, different, and written down nowhere else. Plugins own the strip's contents: ```ts await neosh.status.set("model", { text: "opus", keys: "^P", align: "right", priority: 10 }); ``` ### What the plan has left — `^L` ``` PLAN ───────────────────────────────── ██████░░ 75% Session 27m ████████ 96% Weekly 8h ████████ 100% Weekly ·… 8h extra usage off ``` At the foot of the sidebar, because unlike the context meter it is not about the conversation you are in — it is the same number whichever one you open, and it is the thing you want to have seen *before* starting something long. A rolling allowance is what actually stops work, and hearing about it for the first time as a refusal twenty minutes into a turn is hearing about it too late. It cannot be counted, only reported. A percentage of a subscription's allowance is not a function of anything on this machine — a turn you ran in `claude` directly spent it too — so neosh keeps whatever the vendor last said and draws how old that is. `claude` reports it on a line in the middle of a turn; `codex` pushes it when it moves. One row per window, never collapsed to the worst of them: a session limit that is nearly full comes back in an hour and a weekly one does not, and that difference is the whole of what you would do differently. The `▸` marks the one that binds — vendors run several at once and only one of them is the reason a request would be refused — and the colour is the **vendor's** grade of its own limit, not a threshold neosh picked. `^L` opens the panel behind it: the same gauges full width with a sparkline of how each has moved, and under them where the time actually went — tokens and their money-equivalent by day or hour, per model, read from the agents' own transcripts rather than from neosh's conversations, so a turn you ran outside neosh is in it. | Key | Does | |---|---| | `1` `7` `3` `9` | A day, a week, a month, a quarter | | `[` `]` | One step shorter or longer | | `⇥`, `t` `c` | Tokens or what they would have cost | | `r` | Ask the provider again, now | | `j` `k` | Move | | `q`, `Esc` | Close | The cost is API-equivalent, not money spent: a subscription bills separately and a plan turn costs nothing extra at all. It is there because it is the only common unit that puts Opus and Haiku on one axis. A model with no published rate still appears — its tokens count and its cost does not, marked `*` — because dropping it would make the busiest week look like the quietest. The two numbers never share an axis. An opaque percentage and a token count do not convert into each other, and a chart that drew them together would be inventing an exchange rate. ```toml [options] "usage.sidebar" = true # the strip at the foot of the sidebar "usage.warn_at" = 90 # say something the first time a limit passes this. 0 to never "usage.days" = 30 # the span the panel opens on "usage.poll" = true # ask while nothing is running — see below "usage.show_tokens" = true # the running token total, beside the context meter ``` `usage.poll` is the one worth a sentence. Nothing reports an allowance while nothing is running, and at rest is exactly when the question gets asked — so neosh asks. `codex` has a request for it that costs nothing. Anthropic has no such door: the only credential on the machine that can ask is the `claude` CLI's own login, and neosh reads it. That is the same trade the `claude-cli` driver already makes, and the rules do not bend for it — the token is read at request time, never written anywhere, never logged, and structurally absent from every event a plugin can see. What crosses that boundary is percentages. Turn it off and the gauges show what the last turn reported, saying how long ago that was. Everything here is ordinary API. The strip is a `sidebar.section` contribution, the panel is a buffer with a `kind`, every key is a named command, and `neosh.quota.report()` lets a provider you wrote publish its own vendor's allowance into the same strip. ### The row under the composer Under the field you type into is a row of shortcuts: `⏎ send`, `^P model`, `^T conversations`, and so on. It is not a hard-coded list. Every entry is registered by whoever owns the feature: ```ts await neosh.hint.set("model", { keys: "^P", label: "model", priority: 10 }); ``` Which means the row is always true. A plugin in `plugins.disabled` takes its shortcut with it, rather than leaving a key advertised that no longer does anything — and a plugin you install adds its own without neosh knowing it exists. Entries sort by `priority` and are dropped from the end when the terminal is too narrow, so the lowest priority is the one you would most want kept. Nothing is ever cut in half: half a shortcut reads as a key that exists and does something else. `ui.hints` is off by default, so the row is there for a plugin that has something worth putting on it rather than by default; the full list is on the help key either way. ### What a turn is doing A tool call is one line: a dot, the tool's name, and what it is about. What came back goes under it. ``` ⏺ Bash(wc -l AGENTS.md) ⎿ 168 AGENTS.md ⏺ Read(crates/neosh/src/host.rs) ⎿ //! The chat host. //! //! Owns the conversation, the transcript buffer and every key that is not a plugin's. … +4482 more lines ``` The **dot** is the only thing in the transcript that says whether something is still happening: it pulses while the call runs, and settles green or red when it comes back. Colour rather than shape, because a glyph that changes to mean "finished" makes the column impossible to scan. The **name** is whatever the tool calls itself. Mapping `Read` onto something friendlier would invent a second vocabulary for the one the model is already using, and the moment they disagree the transcript is describing a tool that does not exist. A path is shown relative to the conversation's directory, because the absolute part is the part you already know — and once the line is clipped to fit, the part you already know is all that is left. `chat.tool_output_lines` says how much of a result to show; `0` gives the line count and nothing else, and an error always shows at least its first line whatever the setting says. `chat.show_tools = false` hides the cards entirely, again except for errors: the setting is about noise, not about hiding failures. **The working line stays under the answer** for as long as the turn is running. A turn that has written a sentence and gone off to run three more tools is still working, and a chat that looks finished while the footer says otherwise is a chat you stop believing. Drivers that run their own loop — `claude-cli`, `codex-cli` — report their tool calls the same way. They are running the tools themselves, and neosh is telling you about it as it happens rather than catching up when the turn is over. ### Saying something while it is working Typing while a turn is running does not start a second turn and is not refused. It is **steering**: the message is held, shown under the composer, and taken into the running turn at the next gap — between one round of tool calls and the next, or in place of the turn ending. The model sees it while it is still working, and can change what it does next. ``` ⏺ Read(src/main.rs) ⎿ //! The agent loop. ✳ Working… 8s · 1 queued · esc to interrupt ``` It is not injected into the provider stream, because a stream in flight cannot be interrupted without discarding what has already been generated. The gap between rounds is the earliest honest moment. It does not appear in the transcript until it has actually been said — a transcript that shows a question nobody has been asked is a transcript that is lying. And if the turn ends before there is a gap, the message becomes the next turn rather than being quietly dropped. Drivers that run their own loop (`claude-cli`, `codex-cli`) have exactly one round trip per turn, so steering them means the message lands as the next turn. That is a property of those CLIs, not a setting. What is queued belongs to the conversation you typed it in. Switch away and it stays there, waiting for that turn's next gap. ### Working on more than one thing at once A turn belongs to its conversation, not to the program. Several run at once, and switching between them is never refused — `^T` to move, or `↵` on any row of the project panel, with a model still answering behind you. A conversation that is working says so wherever it appears. In the panel it carries `◍` and the time its turn has been running; the one you are in gets the spinner: ``` ▾ neosh ★ 3 ▸ the flaky test in ci 1m 4s ◍ rename the extmark api 22s a question from yesterday 3h ``` Switching back puts you in the middle of the answer rather than before it: what the turn has already said is redrawn, with the elapsed clock still counting from when it started. `Esc` and `^C` interrupt the turn of the conversation you are looking at — the only one you could have meant. Closing a conversation cancels its turn, because there is about to be nowhere to put the answer. Archiving does not: putting a conversation away is about the list you read, not about the work. Sending twice in *one* conversation is still steering rather than a second turn. That is a different question, and the answer to it is above. ### What the agent may do The footer says what the agent is allowed to do without asking, and `⇧⇥` changes it: | | | |---|---| | `ask` | prompt before writing, running or connecting. The default | | `allow-listed` | only what `config.toml` already permits | | `full access` | no prompts | | `deny` | refuse everything; read-only | **Workspace containment applies in every mode, including full access.** A path outside the workspace is refused whatever the setting says: "full access" means not being asked, not reaching the rest of the disk. `⇧⇥` opens a list rather than cycling, because full access is one keystroke from `ask` in any cycle and arriving there by holding a key down is exactly the accident worth designing against. `permission.cycle` is bound to nothing by default, for people who want it. The mode is for this session only and is never written to a file. A mode you switched on to get through one task should not still be on next week — the way to make it permanent is `[permissions] mode = "…"`, which is a thing you did on purpose. ### How an answer is drawn Answers are rendered as markdown as they arrive: headings lose their hashes, `- ` becomes a bullet, fenced code is indented under its language, and `**bold**` / `*italic*` / `` `code` `` / `[text](url)` have their markers **removed** rather than shown. A terminal can draw bold; drawing the asterisks as well is showing the same thing twice. The rendering is incremental, and the rule is that a block is settled once it cannot change — a paragraph followed by a blank line, a heading with its newline, a fence with its closing back-ticks. Settled blocks are drawn once and never touched again; only the trailing partial block is redrawn per tick. Re-parsing the whole answer on every token is quadratic in its length, and the place that would show is the tail of the long answer you actually care about. Tables are laid out in columns, with alignment honoured and a rule under the labels rather than a box around everything — the job is separating the labels from the data, and a full grid spends four times the ink saying it. A table too wide to line up becomes one block per row instead: ``` Provider Kind Key ────────────────────────────────── Claude plan none OpenAI api key $OPENAI_API_KEY ``` Columns running off the right edge would mean guessing which value belongs to which label, which is worse than not having a table. One thing is deliberately not done: **no syntax highlighting inside fences**. Doing it properly is a grammar per language; doing it with a regex over keywords colours the wrong words in exactly the code you are reading closely. A fence gets one colour and its language in the corner. `chat.markdown = false` shows exactly what the model sent, which is what you want when the question is "what did it actually say". ### Theme and motion ```toml [options] "ui.theme" = "dark" # or "light" "ui.motion" = true # text that moves while something is happening "ui.hints" = false # a shortcut row under the composer; off, the sidebar says the same keys "ui.ascii_only" = false # for terminals without a decent font "ui.nerd_font" = false # brand glyphs for providers, where the font has them "ui.images" = true # draw pictures in the transcript, where the terminal can ``` ### Pictures A picture in the transcript — one you attached with `^V` or dragged in, or one the agent read with a tool — is drawn as the picture on a terminal that can draw one, and as its name, `[png · shot]`, everywhere else. Nothing is configured: at startup the terminal is asked whether it speaks the kitty graphics protocol, which kitty, Ghostty, WezTerm and Konsole do, and tmux passes through with `set -g allow-passthrough on`. A terminal that does not answer keeps the name. How big the picture draws is the terminal's decision too — it knows how many pixels a cell is and nothing above it does — bounded to half the window and never enlarged, so an icon stays an icon. It is one row of the transcript however tall it draws, exactly as a row that wraps is: `j` steps over it, `v` and `y` copy its name. `ui.images = false` keeps the names on every terminal. `NEOSH_NO_IMAGES=1` in the terminal's own environment does the same for one terminal, which is the switch for "my terminal claims to draw pictures and gets it wrong" — a decision the terminal has to make before any configuration has loaded, and one a workspace viewed over SSH cannot make for the laptop it is viewed from. Motion is reserved for one thing: *something is happening and you cannot see it yet*. While a turn is in flight the working line sweeps — a band of brightness travelling along the word — because a still screen and a wedged process look identical, and a sweep reads as aliveness at the edge of vision without asking to be looked at. `Status.Streaming` sweeps; `Status.Pending` pulses, for waiting on something outside the program. The **frontend** does this, on its own clock, over whatever the core last wrote — so a shimmer costs nothing above the terminal boundary and stops the moment the animated row scrolls out of sight. Without truecolor the same band renders as bold rather than as nothing. `ui.motion = false` removes the movement and keeps the colour; it never makes text disappear. The theme is a set of semantic groups — `Status.Working`, `Git.Added`, `Meter.Fill` — that plugins link to rather than choosing colours. Override any of them from `init.ts` and a theme switch will leave yours alone: ```ts await neosh.hl.define("Status.Working", { fg: "#ff00ff", bold: true }); ``` Motion is one shared 100 ms clock and costs about 1% of a core and under 1 KiB/s, which is why it stays on over SSH. ### Approvals `permissions.mode = "ask"` prompts before the agent runs a command or reaches the network. Reading files inside the workspace is allowed in every mode but `deny` — the point of `ask` is the things that leave the project. An answer of "allow for this session" is held **in memory only**; making it permanent is a line in `config.toml`, not a keystroke. With the `approvals` plugin disabled, `ask` mode refuses anything it would have prompted about rather than allowing it. ### Choosing a model `` opens a picker in two panes: your providers on the left, the models each one serves on the right. ``` Model ⇧⇥ providers > PLANS │ ❯ Claude Opus 5 Frontier Most capable for complex work ✳ Claude ✓ │ Claude Fable 5.1 Frontier Deepest reasoning and long-running work ⬢ Codex ✓ │ Claude Sonnet 5 Balanced Best for everyday tasks API KEYS │ Claude Haiku 4.5 Fast Fastest, for quick answers ✳ Anthropic ! │ ▸ 6 superseded LOCAL │ ▲ Ollama │ ────────────────────────┴────────────────────────────────────────────────────────── ↵ use ^S sign in ^R refresh ^A add ^D remove esc close ``` The rail is grouped by **what a turn costs you**, which is the distinction one flat list could not make: | | | | |---|---|---| | **PLANS** | a subscription you already pay for | no key, nothing stored | | **API KEYS** | billed per token | see [API keys](#api-keys) | | **LOCAL** | on this machine | costs nothing, needs nothing | The marker at the right edge of each rail entry: `✓` it will work, `!` it needs a key or its CLI is missing, `⨯` no driver provides it. Superseded models fold behind a count — reachable, out of the way, and opened automatically while you are filtering. Typing filters the model list. `⇧⇥` and `←` reach the provider rail, `⇥` and `→` come back, and the title row says which of the two the key would take you to right now — a legend can only tell you a key exists, and a second pane with no visible way into it is a pane nobody finds. The picker's own actions are **chords**, not letters: | | | |---|---| | `^S` | sign in to whatever the rail is on | | `^R` | ask that endpoint again — after adding a key, gaining access, or starting a local server | | `^A` | add a model by id, for something the catalogue has never heard of | | `^D` | remove one you added | Chords because every bare letter the picker takes is a letter the filter can never contain. These used to be `s`, `r`, `n` and `d`, which between them made it impossible to search for "sonnet". `^A` asks twice — the id, which goes on the wire and has to be exact, and the name, which is what you will read in the list forever afterwards. Nothing validates the id, because nothing here can: whether an endpoint serves it is a question only the endpoint can answer, and it answers on the first turn. What you add is kept per provider in plugin state rather than in configuration — it is a note about *this machine's* access, not a decision worth committing to a repository. `^D` takes it back out, and refuses on a model the provider serves, which is not yours to delete. `ui.nerd_font = true` swaps the geometric provider marks for real brand glyphs. Off by default and deliberately not detected: a terminal cannot be asked what its font contains, and guessing wrong draws a row of boxes, which reads as a broken program rather than as a missing font. `ui.ascii_only = true` reduces them to letters. #### What you see before you sign in Every provider lists models whether or not you have a key for it, because the list is *how you decide whether to sign in*. Those entries come from a written-down catalogue: enough to pick from, and never claimed to be complete. The moment a key is present the endpoint's own `/v1/models` decides what exists, and the catalogue keeps only the part it is authoritative about — the display name, the rung and the one-line description, none of which appear in any `/v1/models` response. A model the endpoint does not return is dropped, even if the catalogue lists it: offering one whose first turn is a 404 is worse than offering nothing. Providers that serve other people's models — Groq, Cerebras, Together, Fireworks — have no written catalogue on purpose. Their lineups change weekly, so a list here would be wrong faster than it was useful, and the picker says "discovery needs a key" rather than showing an empty pane. #### When the model you used last has stopped working Reopening a conversation restores the model it was using. If that model can no longer authenticate — an API-key provider, on a machine that does not have the key — neosh moves to one that can, keeps the product line where it can (`anthropic/claude-opus-5` becomes the Opus on your plan, not whatever sorts first), and says so in one line. A stored selection is a *record of what was used*, not an instruction. What you put in `agent.model` is an instruction, and is left alone even when it cannot authenticate: quietly using a different model would be worse than the error you get when you send. ### Moving along the ladder Every model in the catalogue sits on one of three rungs — **Frontier**, **Balanced**, **Fast** — because every lineup worth switching between has three: Opus/Sonnet/Haiku, gpt-5/mini/nano, Pro/Flash/Flash-Lite. ``` model.upgrade one rung up, same provider model.downgrade one rung down model.line opus the current model in a line, wherever it is reachable ``` None of these has a default key. They had `⌥↑`/`⌥↓`, which is a key only on a terminal that has been told to send Option as Alt, and chat mode has no chord left to move them to — so they are commands you reach by name from `^K`, or put on a key of your own. `upgrade`/`downgrade` hold the provider fixed on purpose: "give me something cheaper" is a question about the model, and answering it by also changing your billing would answer a question nobody asked. Neither wraps — at the top, `upgrade` says so rather than dropping you to the cheapest thing in the catalogue. `model.line` resolves a *product line* to the one in it that has not been superseded, which is what people mean by "use Opus": not a pinned id that goes stale. These are ordinary commands. Rebind them like anything else: ```toml # in init.ts await neosh.keymap.set("chat", "", "model.upgrade"); ``` ### Reasoning effort `` picks reasoning effort and whatever else the driver exposes. Those are not a fixed list: they arrive as `ProviderOptionDescriptor`s attached to the model, so a provider plugin that invents a knob gets a working picker for it with no change to the switcher. Switching between two models that share an option keeps your setting; switching to one without it drops the setting rather than sending a value the driver would reject. ### Plans Three plans ship: `claude-cli`, `codex-cli` and `antigravity`. All three are the vendor's own CLI, driven as a provider. None needs an API key, none stores a token, and none is offered unless the program is actually on your `$PATH` — a provider whose every turn would fail with "no such file" is worse than one that is not listed. `antigravity` is Google's `agy`, which carries its own Google sign-in — including the Antigravity access a Google AI subscription comes with, so there is nothing to paste. It is the CLI rather than the ACP runtime Google also publishes: that one is a ~540 MB download a client is expected to fetch, verify and keep updated, and `AuthRef::Cli` exists precisely so a vendor's binary stays the vendor's to install. One thing it cannot do is ask: `agy -p` has no channel for a permission prompt, so outside full access it denies whatever its own `permissions.allow` does not cover. A turn stopped that way says so in the transcript — silence would be indistinguishable from an agent with nothing to say. They are **agent drivers**: `claude -p` and `codex exec` run their own loop, with their own tools, their own sandbox and their own approval policy. So neosh's tool registry is not in play on those turns and `tool.pre` hooks observe rather than gate. Two consequences worth knowing before you pick one: - Neither driver overrides the CLI's sandbox. If a turn stops to ask for approval it wants, the place to change that is `codex`'s or `claude`'s configuration, not neosh's. Loosening somebody else's security policy to make turns run more smoothly is not a decision a wrapper should make. - `codex exec --json` has no token deltas — the protocol reports an assistant message when it is complete. So a Codex turn shows its tool activity live and then its answer all at once. That is the CLI's shape, not a shortcut. Three more come from one driver, because Cursor's `cursor-agent`, xAI's `grok` and Google's `gemini --experimental-acp` all speak the **Agent Client Protocol** — JSON-RPC over stdio. A fourth ACP agent is a line in the catalogue rather than a file of code. ACP has one limitation worth knowing before you pick one. An ACP agent asks its *client* for permission, which means neosh has to answer, and it currently answers from the permission mode alone: under **full access** it takes the agent's allow-once option, and under anything else it refuses and says so. Routing those prompts into the same approval you get for a built-in tool call is the next step and needs the request to travel out of the driver and an answer to travel back. Until then the behaviour is conservative and stated rather than quietly permissive. Adding a plan behind a CLI that speaks neither is a driver plus a line in the catalogue. `AuthRef::Cli { program, login }` is the whole contract: name the program, name the command that logs in, and the account split, the rail grouping and the "not installed — run this" message all follow. A provider whose CLI is not installed still lists what it offers, greyed out, with the command that would fix it at the top. What a provider serves is how you decide whether installing it is worth your afternoon, and an empty pane answers that with silence. ### API keys Only for the **API KEYS** group. A plan signs itself in — if the Claude entries are missing, the `claude` CLI is not on your `$PATH`, and `provider.auth` says so rather than hiding the provider. `provider.auth` lists every configured provider and the state of its account; `provider.key` (built in, so it works with `--clean`) takes a key for the provider you are on. Picking a model that has no key offers the same prompt rather than failing later, when the failure is between you and the question you were asking. **The prompt is the host's, not a plugin's.** Nothing is echoed — you get one bullet per character — and no plugin ever sees what you typed. There is no API call that returns a key, only ones that report where it came from, so a plugin cannot leak what it cannot read. Where it goes: | | Survives a restart | | |---|---|---| | OS keychain | yes | used automatically when `secret-tool` or `security` is installed | | this session | no | the fallback when neither is — say, over SSH with no session bus | neosh tells you which happened. It never writes a key to a file it controls. Four places a key is looked for, in order — one you typed this session, the keychain, a helper program, then the environment. Typed wins, because you typed it after seeing what the environment gave you. A helper is the answer if you already keep secrets somewhere: ```toml [[providers]] id = "anthropic" driver = "anthropic" display_name = "Anthropic" base_url = "https://api.anthropic.com" auth = { kind = "command", argv = ["pass", "show", "anthropic/api-key"] } ``` Anything on `$PATH` works — `pass`, `op read`, `bw get`, `gopass`, your own script. Only the first line of its output is used, so tools that print metadata after the secret are fine. ### Git, and the prompts behind it `git.branch.new` asks what you are about to work on, has a model name the branch, and shows you the name **before** creating it. `git.commit` writes a message from the staged diff and shows you that before committing. Every prompt is a setting, in two layers: ```toml [options] "git.branch.prefix" = "feature/" "git.branch.instructions" = "Start with the Jira key when the message mentions one." "git.commit.instructions" = "Follow Conventional Commits." ``` `*.instructions` is appended to the built-in prompt — the common case. `*.prompt` replaces it entirely, for when appending is not enough; it must still ask for JSON with the documented keys (`branch`, or `subject`/`body`). Instructions still apply on top of a replaced prompt, so a per-project rule in `.neosh/config.toml` layers onto your own prompt. ```toml [options] "gen.model" = "anthropic/claude-haiku-4-5" # what writes names and messages ``` `gen.model` is separate from `agent.model` on purpose: naming a branch does not need a frontier model. Empty uses the conversation's model. Repository *writes* — branch, checkout, stage, commit, worktree — need `permissions = ["vcs_write"]` in a plugin's `plugin.toml`. That is a plugin permission, not the agent's permission mode: those govern what the **model** may do, and the model does not reach git this way. It reaches it through a registered tool, gated like every tool. ## config.toml For setting values without writing code. It can express a subset of what `init.ts` can; it exists so that "use this model" is one line, and so a settings UI has a file it can rewrite. ```toml [agent] model = "anthropic/claude-opus-5" [permissions] mode = "ask" # ask | allow_listed | deny allow_commands = ["cargo"] [options] "chat.show_thinking" = true [[providers]] id = "local" driver = "openai-compat" display_name = "llama.cpp" base_url = "http://localhost:8080/v1" auth = { kind = "env", var = "MY_API_KEY" } plugin_dirs = ["~/src/neosh-plugins"] ``` Unknown keys are an **error**, not ignored. A typo that is silently skipped looks exactly like a setting that does not work. `auth` names *where a key is*, never a key: `{ kind = "env", var = "…" }`, `{ kind = "command", argv = [...] }`, `{ kind = "cli", program = "codex", login = "codex login" }` for a plan behind a vendor CLI, or `{ kind = "none" }` for a local endpoint that wants none. Nothing writes a secret to this file, and nothing reads one from it. A `cli` plan also takes `retired = "…"`, for when the **vendor** has stopped serving it. A plan can end without its CLI going anywhere — the program is installed, it runs, and the account behind it is no longer served — which needs a different sentence from "install it" because the fix is somewhere else entirely. It is the sentence rather than a flag so it can name the date, the tiers that still work and where to go instead. The instance stays listed and its models stay selectable, because a plan can end for one tier and not another and nothing here knows which one is reading; what changes is that nothing *defaults* into it and every list says why. `gemini-cli` ships this way: Google ended Gemini CLI for individual accounts on 18 June 2026 — Code Assist for individuals, AI Pro and AI Ultra together, with "Login with Google" withdrawn alongside them — while Code Assist Standard and Enterprise are unchanged. Use the `google` instance and a `GEMINI_API_KEY` unless you are on one of those. ## Options Options are declared, typed, and owned. You cannot set one that does not exist, and you cannot set the wrong type — both are errors that name the problem. ```ts await neosh.opt.set("chat.show_thinking", true); const n = await neosh.opt.get("chat.show_thinking"); await neosh.opt.all(); // everything declared, with types and defaults neosh.opt.onChange((e) => { /* … */ }); // fires for every option, not just yours ``` Built in: | Option | Type | Default | Effect | |---|---|---|---| | `agent.model` | string | `""` | `instance/model`. Empty picks the first that works. | | `agent.system_prompt` | string | `""` | Replaces the built-in prompt when set. | | `chat.show_thinking` | bool | `false` | Stream reasoning into the chat buffer. | | `chat.show_tools` | bool | `true` | Show a line when a tool runs. Errors always show. | | `chat.tool_output_lines` | int | `3` | How much of what a tool returned to show under it. `0` counts it instead. | | `gen.model` | string | `""` | Model for branch names, commit messages and titles. Empty uses `agent.model`. | | `plugins.disabled` | list | `[]` | Bundled plugins not to load. | That list is short because every entry does something. Plugins declare their own, through the same call the built-ins use: ```ts await neosh.opt.declare({ name: "myplugin.enabled", type: { type: "bool" }, default: true, description: "…", }); ``` Once declared, it is settable from `config.toml` like any other — including by a user who set it *before* your plugin loaded. Held values are applied the moment the declaration arrives. ## Plugins Anything in `~/.config/neosh/plugins//` with a `plugin.toml` loads automatically. To load from elsewhere, from `init.ts`: ```ts await neosh.rtp.add("~/src/my-plugins"); ``` This works because **your config runs before plugin discovery** — it is the thing that decides what else loads. `rtp.add` after discovery is an error rather than a silent no-op. ## Per-project `/.neosh/config.toml` is committable and applies to anyone who opens the repository. Because a config file in a repository you cloned is code someone else wrote, it is split by what it can do: **Applies immediately.** `model` — it can only name a provider instance *you* already configured, so the worst it achieves is picking a different one of your models. `[permissions]` — and only to make things **stricter**; a repository cannot grant itself `curl`. **Waits for `neosh trust`.** `.neosh/init.ts`, `plugin_dirs`, `providers`, `[options]`, `system_prompt`. Anything that runs code or widens what the agent may do. ```sh neosh trust # prints the files, then approves them neosh trust --list neosh trust --revoke ``` Trust is keyed on the **contents of every file under `.neosh/`**, not on the path. Editing any of them — including a module `init.ts` imports — revokes it automatically, so a `git pull` cannot quietly change what runs on your machine. You will be told, and you re-approve after reading the change. `neosh init --project` scaffolds `.neosh/` in the current directory. ## Precedence Later wins: 1. built-in defaults 2. `~/.config/neosh/config.toml` 3. `/.neosh/config.toml` 4. `~/.config/neosh/init.ts` 5. `/.neosh/init.ts`, if trusted 6. command-line flags Flags last, so `--model` always takes effect. ## Timers ```ts const stop = neosh.timer.every(1000, () => { /* … */ }); ctx.subscriptions.push(stop); // cancelled when your plugin unloads const redraw = neosh.timer.debounce(150, () => render()); neosh.agent.onToken(redraw); // runs once, 150ms after the tokens stop ``` `setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` exist as globals too, for ported code. Prefer `neosh.timer` — the globals cannot know who called them, so they are **not** cancelled when your plugin unloads, and an interval that outlives its plugin doubles up on every reload. Repeating timers have a 1 ms floor, so `setInterval(f, 0)` is `setInterval(f, 1)`. One-shots are not clamped. ## What the runtime gives you The plugin runtime is a bare `deno_core` — a sandbox by construction, not by policy. You get the ECMAScript built-ins, `console`, `queueMicrotask`, `Deno.core`, and the timers above. You do **not** get `fetch`, `TextEncoder`, or any filesystem access. Anything with an effect goes through the neosh API, which is what makes hooks and permissions meaningful. ## Not yet - **`:set` writing back to disk.** The machinery knows which options you changed; nothing persists them. - **Buffer- and window-local options.** Global only for now. - **A plugin manager.** `rtp.add` and `plugins/` are the manual path; a manager is a plugin, and nothing in the core needs to change for one to exist. - **A command line.** Commands are invoked by key, from the palette (``), or from code; there is no `:` prompt yet. - **Reload closing what a plugin opened.** A float opened during `activate` opens again on reload, and an in-flight turn keeps streaming. Use `ctx.subscriptions` for anything that should not outlive your plugin. ==================== # Canonical guide: writing a plugin (docs/plugins.md) ==================== # Writing a plugin A plugin is a directory with a `plugin.toml` and an entry module. Drop it in `~/.config/neosh/plugins/` and it loads. ``` my-plugin/ ├── plugin.toml └── main.ts ``` ```toml name = "my-plugin" version = "0.1.0" entry = "main.ts" description = "…" # Declared up front, and enforced. Reading git state needs nothing; changing it needs this. # # tools register a tool the model can call # providers register a model provider driver # hooks_blocking take a hook that can rewrite or veto (an observer needs nothing) # raw_cells claim a raw cell surface # vcs_write branch, checkout, stage, commit, worktree # # Everything else — windows, buffers, keys, floats, options, vars — needs nothing declared: it is # no more privileged than what the person sitting there can already do. permissions = ["vcs_write"] # Plugins this one builds on. Each loads and activates first, `import … from "plugin:"` # resolves to it, and a name nothing provides fails at startup with the name in the message. requires = ["sidebar"] # Soft ordering: load after these if they are present, without needing them. after = ["usage"] # What this plugin offers others — listed by `ext.points()` and the plugins panel, and how a # contribution to a point nobody reads gets reported instead of silently ignored. [provides] points = ["acme.tasks.section", "acme.tasks.action", "acme.tasks.decoration"] kinds = ["acme.tasks"] vars = ["acme.task.due"] # Omit for a plugin that loads at startup. Present, it is held until one of these fires: the # command is registered on its behalf and the press replayed once it is up. [activation] on_command = ["acme.tasks.toggle"] on_event = ["neosh.win.enter"] on_kind = ["neosh.sidebar"] ``` ```ts import type { PluginContext } from "@neosh/api"; export async function activate({ neosh, subscriptions }: PluginContext) { const d = await neosh.cmd.register("my.hello", () => neosh.notify("hi"), { desc: "Say hello", }); subscriptions.push(d); // disposed when the plugin unloads await neosh.keymap.set("chat", "", "my.hello"); } export async function deactivate() {} // optional: runs on unload and at shutdown, bounded to a second ``` `neosh init` writes the API types next to your config, emitted by the binary you are running, so `npx tsc --noEmit` checks against the exact version you have. **Plugins are transpiled, not type-checked, at load** — run `tsc` yourself; it is the only thing that catches a type error before it becomes a runtime one. --- ## Shipping one, and installing somebody else's A plugin is published by being a git repository with a `plugin.toml` at its root. There is no registry: a URL is already a globally unique name, and it works for a fork, a private repository and the branch you are writing. ```sh neosh plugin add https://github.com/someone/neosh-thing # or git@host:someone/thing neosh plugin list # everything that loads here neosh plugin update # ff-only pull, all of them neosh plugin remove thing # asks first ``` `add` clones, reads the manifest with the same code startup reads it with, and only then moves it into place — so a plugin built against another protocol version, or missing the file its `entry` names, fails here with a sentence rather than at your next startup as a line in a log. The directory is named by `name` in the manifest, not by the URL: a plugin's id is that name, and two directories claiming one id would make which of them wins depend on how a filesystem happened to list them. **Two places, and they mean different things.** `~/.config/neosh/plugins/` is yours — put a plugin you are *writing* there and it is discovered as it is, so every save is live. `neosh plugin remove` will not touch it, because `add` did not put it there. Installed plugins live under your data directory and are checkouts neosh manages. `plugin_dirs` in `config.toml` adds any other directory. --- ## The bundled plugins are the worked examples The sidebar, the model switcher and the git actions live in `plugins/builtin/`. They are ordinary plugins that happen to be embedded in the binary — no private API, no exemptions, and CI type-checks them against the published `@neosh/api`. Read them; they are the reference for everything below. --- ## `@neosh/api/ui` Widgets, built entirely on the public API. Nothing here is privileged — vendor your own copy and it behaves identically. ### `picker` ```ts import { picker } from "@neosh/api/ui"; const branch = await picker(neosh, branches.map((b) => ({ label: b.name, detail: b.subject ?? "", keywords: b.upstream ?? "", // matched by the filter, not shown value: b.name, })), { title: "Switch branch", width: 76 }); if (branch !== null) await neosh.git.checkout(branch); ``` Resolves to the chosen value, or `null` if dismissed. Typing filters, `↑`/`↓` and ``/`` move, `` clears the filter, `` accepts, `` dismisses. Everything else falls through to your bindings, so `` still quits while a picker is open. `onHighlight` fires as the cursor moves — use it for a live preview. A picker can be a *finder* rather than a filter — pass `source` and the rows are replaced from it on every keystroke instead of being fuzzy-filtered locally. That is what you want when the candidates cannot all be fetched up front: ```ts const dir = await picker(neosh, [], { title: "Add project", source: async (query) => (await neosh.path.complete(query)).map((p) => ({ label: p, value: p })), freeform: (query) => query.trim() || null, // accept what was typed, not just what was offered }); ``` Calls are generation-checked, so a slow source answering late cannot replace a newer list with an older one; and `accept` waits for the fetch in flight, so typing faster than the source answers takes the row for what you actually typed. `pathPicker` is that, pre-wired for directories. A picker can also carry *verbs*. `onKey` is checked before the widget's own handling, and `ownKeys` claims keys a binding elsewhere would otherwise take — the archive browser puts "put it back" and "delete it" on rows this way, rather than opening a second modal to ask which you meant: ```ts const chosen = await picker(neosh, items, { hints: "↵ restore ^U put back ^X delete esc close", ownKeys: ["", ""], // both are bound elsewhere; claim them or they never arrive async onKey(key, ctx) { if (key.key.code.kind !== "char" || !key.key.mods.ctrl) return; if (key.key.code.c === "u") { await putBack(ctx.item); items.splice(0, items.length, ...(await rows())); // mutate the array you passed in return "reload"; // …and the list catches up in place } }, }); ``` Return `"close"`, `"reload"`, `"handled"`, or nothing at all to let the widget have the key. Chords only in a picker that filters: every bare letter you take is a letter the filter can never contain. Widget keys come from `ui.keys.*` and are claimed window-scoped while the widget is open, so they outrank global bindings and are released with the window. You get that for free by using these widgets; there is nothing to declare. ### `prompt` and `confirm` ```ts const name = await prompt(neosh, "Branch name", { initial: suggested }); const ok = await confirm(neosh, "Stage everything?"); // For anything that cannot be undone. Reads `ui.confirm_destructive`, starts the cursor on the // answer that changes nothing, and draws the other one in the theme's error colour — so use this // rather than `confirm` and the setting means one thing everywhere without your plugin knowing it // exists. if (!(await confirmDestructive(neosh, `Delete ${name}?`, { yes: "Delete", no: "Keep", detail: [`${count} messages, in ${project}.`, "Archiving keeps every word of it."], }))) return; ``` The bar is *irreversible*, not merely significant. Closing a panel or switching a model asks nothing, because you can put those back. On the other side of that line there are no exceptions: ask every time, including for the cheap case, because a dialog that appears only sometimes is one the user's fingers are already through by the time it matters. `detail` is what makes it worth stopping for. "Are you sure?" is a speed bump; the number of messages, the project they are in and what the alternative is can be answered without leaving the dialog to go and check. `y`/`n` answer it outright, and the question wraps rather than being clipped to one line. ### `railPicker` Two panes: a rail of categories, and the rows belonging to whichever one is selected. What the model switcher is built from. ```ts const chosen = await railPicker(neosh, { title: "Model", rail: providers.map((p) => ({ mark: { text: "✳", hl: "Brand.Anthropic" }, // drawn in its own colour badge: { text: "✓", hl: "Account.Plan" }, // one column, at the right edge group: "PLANS", // headings, in first-seen order label: p.name, value: p, })), items: async (p) => rowsFor(p), // called on open and on every rail move hints: "↵ use ⇥ panes esc close", placeholder: "nothing here yet", onKey: (key, ctx) => (key.key.code.c === "s" ? signIn(ctx.rail) : undefined), }); ``` Reach for it when one list would be answering two questions at once. "Which provider" and "which model" have different cardinalities — a dozen against hundreds — and flattening them puts the thing you want thirty rows under something you do not use. A `PaneItem` may name a `section`, which folds its rows behind a count and opens automatically while a filter is active: "no matches" with the match folded away is the worst possible answer. Both panes are one buffer with a rule down the middle, not two floats. Two floats cannot be kept adjacent without each of them knowing the other's width, and neither of them may measure anything. ### Helpers `fuzzy`, `stateLetter`, `statusPrefix`, `shortenPath`, and `defineHighlights` (which links `PickerSelected` and `PickerMatch` to groups a theme already defines). --- ## Columns are UTF-8 byte offsets Every column in this API is a byte offset, matching Neovim and for the same reason: it keeps display-width math in one place, the frontend. ### Measuring, when you are laying one out ```ts import { width, clipToWidth, padToWidth } from "@neosh/api"; width("日本") // 4 columns, 2 code points padToWidth(name, 20) + "│" // a rule that lines up ``` Use these for anything with a column in it. JavaScript offers `String.length` (UTF-16 units) and `Array.from(s).length` (code points), and **both are wrong** for the text a model produces: two CJK characters are 2 code points and 4 columns, a waving hand is 1 and 2, a combining accent adds a code point and no column. Padding with either draws a ragged rule the first time a non-ASCII name appears in your list. They are synchronous — ops, not host calls — so calling one per row costs nothing, and they use the same measurement the renderer does, so your layout and the frame agree by construction. ## Text that moves A highlight group may carry an animation, and the frontend animates whatever does: ```ts await neosh.hl.define("MyPlugin.Working", { link: "Status.Streaming" }); // sweeps await neosh.hl.define("MyPlugin.Waiting", { link: "Status.Pending" }); // pulses ``` Set the group once and stop thinking about it: nothing in your plugin drives the movement, there is no timer to own, and it costs you no API calls at all. Animating text yourself would mean re-setting an extmark per character per tick — hundreds of calls a second across the runtime boundary to move a highlight two columns. Motion is reserved, by convention, for *something is happening and you cannot see it yet*. A screen where several things move is a screen where none of them means anything. `ui.motion = false` removes the movement and keeps the colour, and your plugin needs no code for that case. JavaScript's `.length` is UTF-16 code units. It agrees with bytes only for ASCII, so using it to place a highlight on a line containing an emoji or any CJK puts the mark in the wrong column — or inside a character. ```ts import { byteLength, byteOffsets } from "@neosh/api"; await neosh.ns.mark(ns, buf, row, 0, { hlGroup: "Title", endCol: byteLength(line) }); ``` `byteOffsets(s)[i]` is the column of the `i`th code point, with a final entry holding the total. Match with `Array.from(s)` to get code-point indices, then convert. --- ## Raw keys Keys bind to command *names*, never callbacks — that is what makes every binding listable and remappable. For a widget that needs raw input, claim what nothing else wanted: ```ts const win = await neosh.float.open(buf, { focusable: true, closeOnBlur: true }); await neosh.focus.push(win); const release = await neosh.keymap.capture(win, "my.key"); ``` While `win` is focused, keys no binding claimed are sent to `my.key` with a `KeyContext`. Bindings still win, so you cannot accidentally take away the key that quits. The capture is released when the window closes, when a `close_on_blur` float is dismissed, or when your plugin unloads. `lhs` takes Neovim notation, including multi-key sequences and ``, which is substituted from `mapleader` at the moment you call `set`. A sequence the user starts and does not finish is replayed as ordinary input after `timeoutlen`, so binding `gd` does not make `g` untypeable. --- ## Git ```ts const status = await neosh.git.status(); // rejects with not_found outside a repository await neosh.git.branches({ includeRemote: true }); await neosh.git.diff({ kind: "staged" }, { stat: true }); await neosh.git.commit("fix: the thing"); // needs `vcs_write` ``` Reads need nothing declared. Writes need `permissions = ["vcs_write"]` in your manifest — a *plugin* permission, not the agent's permission mode. Those govern what the model may do; the model reaches git through a registered tool, gated like every tool. ## Generation ```ts const branch = await neosh.gen.field( 'Return JSON with one key: branch.\n\nUser message:\n' + text, 'branch', ); ``` Nothing here enters session history, so asking for a commit message does not change what the agent believes it was asked to do. `gen.json` tolerates what models actually return — code fences, a "Sure!" preamble. `gen.field` is that plus the one thing `gen.json` cannot do: when the answer is a single value, a model asked for `{"branch": …}` sometimes replies `fix/composer-paste` and nothing else. It did the work and left the envelope off, and `gen.json` rejects it. Name the key and both are the same answer. For one value only — a commit message is a subject *and* a body, and there is no telling which half a lone paragraph is — and only for a value you would know was wrong on sight. A branch name is that. A thread title is any short line, and so is a refusal or a driver's own error message; where nothing tells an answer from a remark, the envelope is the evidence and `gen.json` is the call. Make your prompts options rather than constants, in two layers: `.instructions` appended to your default, `.prompt` replacing it. That is what the bundled `git` plugin does, and it is why "always prefix branches with the ticket number" is one line of `config.toml` rather than a fork. --- ## Moving and editing text Your plugin's text field should behave like the composer, and it does — they ask the same question: ```ts await neosh.edit.move(win, "word_left", { select: true }); await neosh.edit.apply(win, { kind: "insert", text: "hello" }); await neosh.edit.apply(win, { kind: "delete_word_back" }); const chosen = await neosh.edit.selection(win); await neosh.edit.copy(chosen); ``` Motions are verbs the core resolves rather than positions you compute, because grapheme and word boundaries are genuinely hard and nobody should have to get them right twice. `move` with `select: true` extends a selection from wherever it was anchored — anchoring first if nothing was — and without it the selection is dropped. That is shift-and-arrow, in one call. Selection is drawn as an extmark in a namespace the core reserves, linked to `Visual`, so your theme already styles it and no rendering code had to learn a new concept. A selection is two positions, and the *shape* is what says whether the character the cursor is on is in it: ```ts await neosh.edit.selectShape(win, "inclusive"); // the cursor is on a character, and it is selected await neosh.edit.selectShape(win, "line"); // whole rows, in whichever direction it runs await neosh.edit.cursorShape(win, "block"); // and the caret says so before any key is pressed ``` `exclusive` is the default and is what a text field wants: the cursor sits *between* two characters, so one that swallowed the character to its right would delete a letter nobody highlighted. A normal mode wants the other answer — `v` then `y` copies a letter rather than reporting an empty selection — and a `line` selection snaps both ends to whole rows, including when it is extended upwards. Dropping a selection puts the shape back to `exclusive`. `cursorShape` is per window. A block caret is painted by the frontend over the character it is on (reverse video unless a theme defines `Cursor`) *and* asked of the terminal, which is what a screen reader follows. Give it back when your mode ends: a shell that inherits a block cursor because something exited mid-mode is a terminal that looks broken. `copy` goes out as OSC 52 through the frontend, which is the only thing holding a terminal. That is also why it works over SSH, where a clipboard library would be talking to the wrong machine. --- ## What your plugin remembers The runtime has no filesystem, so anything that has to survive a restart goes through the host: ```ts await neosh.state.set("favorites", ["/home/me/project"]); const pinned = (await neosh.state.get("favorites")) ?? []; await neosh.state.remove("favorites"); ``` Keyed by your plugin id — which the host knows and you cannot forge — so no other plugin can read or clobber it. `get` returns `null` when nothing was stored. Written atomically, one JSON object per plugin under `$STATE/plugin-state/`. Use it for arrangement, not configuration. Which sections a panel of yours has folded belongs here; a *setting* belongs in `neosh.opt`, because an option is something the user wrote in `config.toml` and a plugin that rewrites that file because someone pressed a key is one they stop trusting with it. And nothing secret belongs here: it is plain JSON on disk. ## What everybody remembers — `neosh.vars` The same thing, minus the privacy, plus a scope. A var describes the workspace, a conversation or a project, and anyone may read or write it. ```ts import { projectScope, sessionScope } from "@neosh/api"; await neosh.vars.set(projectScope(cwd), "sidebar.favorite", true); const pinned = await neosh.vars.get(projectScope(cwd), "sidebar.favorite"); const all = await neosh.vars.all(projectScope(cwd)); // one round trip, the whole project neosh.vars.onChange((e) => { // whoever changed it, including you if (e.scope.scope === "project") redraw(); }); ``` The line between this and `state` is who has a reason to look. A fold set is yours; "this project is a favourite" is not, and while it was, a second panel started with no favourites and there was no way to tell it about them. Pinning a project from your own plugin is now the three lines above. Namespace your keys — `sidebar.favorite`, `acme.colour` — for the reason options are namespaced. Nothing stops two plugins choosing `colour`; a prefix is what makes them not want to. Default to `state` and reach for `vars` when somebody else genuinely needs the value. A workspace where every plugin writes its scratch into a shared table is one where nobody can rename anything. And note that a var write is a *file* write: right for a keystroke like `f`, wrong for anything that happens on every cursor move — publish that as an event instead. --- ## Building on another plugin Neovim's `require("nvim-tree.api")`, in two spellings. ```ts // Typed and free of round trips, for a plugin you `requires` in the manifest. Module-level state // is shared: this is the very module the host activated, not a second copy of its source. import { api as sidebar } from "plugin:sidebar"; const row = sidebar.cursor(); // the Target under the sidebar's cursor, or null for (const t of sidebar.rows()) { /* … */ } // Through the host, for a plugin you would rather not depend on, or for the host's own commands. // Whatever the handler returned, as JSON; `null` for one that returned nothing; rejects with the // handler's error, or `not found`. const row = await neosh.cmd.call("sidebar.cursor"); ``` A command *returns* now: `cmd.register("acme.count", () => ({ n }))` is a question anybody can ask with `cmd.call`, and `cmd.exec` is still the key press that does not wait. Registrations stay data so they can be listed and disabled; *queries* get an answer, because faking one out of an event, a correlation id and a reply command is a worse RPC written once per plugin. `init.ts` loads before every plugin, so a static `plugin:` import there would find nothing. Use a dynamic one once the workspace is up: ```ts neosh.event.on("neosh.ready", async () => { const { api } = await import("plugin:sidebar"); }); ``` Type-checking resolves `plugin:` against `plugins//main.ts` beside your config, the plugins `neosh plugin add` installed, and — for the bundled ones — `types/builtin//main.ts`, written by `neosh init` and refreshed at startup from the binary you are running. **Who wins a name.** One rule for keys, commands and highlights: a bundled plugin offers *defaults*, a plugin you installed is a *choice*, your own `init.ts` is the *last word*. A lower tier never takes a key, a command or a colour a higher one holds — silently, because a default finding its key taken is the ordinary case — and within a tier the later registration wins, which is load order. A command name a higher tier holds is not refused to the lower one: the registration is kept in the wings and comes back when the higher tier lets go, so a panel's `activate` never fails over a verb the user had already decided to own. --- ## Putting something in somebody else's panel Four mechanisms, and between them you should not have to fork a bundled plugin to change it. ### Bind a key inside a panel you did not open A panel declares what it is with a buffer *kind*, and you bind against the kind rather than against a window whose id is private to whoever opened it and changes every time it is toggled. ```ts // In your init.ts. `x` archives a conversation by default; this replaces it. await neosh.keymap.set("chat", "x", "acme.mine", { scope: { kind: "buf_kind", name: "neosh.sidebar" }, desc: "Do it my way", }); ``` Resolution is window → buffer → kind → global, first match winning. Your binding is an ordinary one: `^Z` lists it under the panel's section, `^K` runs the command, and the panel's own default loses to it because a default that overwrites a choice is not a default. Publish a kind for anything of yours that is more than a scratch buffer — it is one argument, and it is the difference between a panel somebody can extend and one they can only replace: ```ts const buf = await neosh.buf.create({ name: "[tasks]", scratch: true, kind: "acme.tasks" }); const open = await neosh.win.ofKind("neosh.sidebar"); // find somebody else's, too ``` ### Contribute rows, verbs and marks A contribution point is a name a plugin agrees to read. The sidebar reads three: ```ts // Rows in the column. Re-contributing under the same id replaces, so this is also how you update. await neosh.ext.contribute("sidebar.section", "todo", { title: "ACME", before: "add", // a slot — projects, add, archived — or another // section's id; `at: "above" | "below"` is coarser rows: [{ text: "Ship the thing", command: "acme.open", args: ["thing"] }], }); // A mark on a row the panel already draws, keyed by what the row is about. The git plugin's stat // strip is exactly this. The name is clipped to make room for the badge; `hl` colours a // row the panel left plain; `right` replaces the count or the age on a row that is not busy. await neosh.ext.contribute("sidebar.decoration", `prs:${cwd}`, { target: { project: cwd }, // or { session: id } badge: { text: "2 PRs", hl: "Accent" }, }); // A badge is often several facts rather than one, and a strip drawn in a single colour is a string // you read rather than a row you glance at. `parts` is the same badge in as many colours as it has // things to say — what `↓2 ↑5 ~7` on a project row is — and a part with no `hl` is a separator. await neosh.ext.contribute("sidebar.decoration", `ci:${cwd}`, { target: { project: cwd }, badge: { parts: [{ text: "✓12", hl: "Git.Added" }, { text: " " }, { text: "✗1", hl: "Git.Conflict" }] }, }); // A verb on a row. The panel binds the key and invokes your command with the row under the cursor: // ["session", cwd, id] or ["project", cwd]. await neosh.ext.contribute("sidebar.action", "touch", { key: "t", label: "touch", command: "acme.touch", on: "session", // or "project", or "any" }); ``` Your contributions go when your plugin does, so `plugins.disabled` takes your rows with it. A point is just a string: reading one in a panel of your own is `ext.list(point)` plus a redraw on `ext.onChange`, and that is the whole protocol. Declare the points you read under `[provides]` so `ext.points()` can say who reads what, and a contribution to a point nobody reads is reported at startup with the nearest real one — `did you mean "sidebar.section"?`. ### Follow its cursor The sidebar says where it is: `sidebar.cursor` on the event bus on every move, with the row under the cursor as `data`; `sidebar.cursor` and `sidebar.rows` as commands for `cmd.call`; `cursor()` and `rows()` on `plugin:sidebar`. ### A panel of your own, on `ListPanel` Everything above, for the price of a kind and a `rows` function: ```ts import { ListPanel } from "@neosh/api/ui"; const panel = await ListPanel.create(neosh, { kind: "acme.tasks", dock: "right", size: () => 30, rows: () => tasks.map((t) => ({ text: ` ▸ ${t.title}`, value: t })), key: (t) => ({ task: t.id }), // what decorations target, and what anchors the cursor kindOf: () => "task", // what a contributed action's `on` may name onOpen: (t) => openTask(t), }); subscriptions.push({ dispose: () => panel.dispose() }); ``` That is a buffer of kind `acme.tasks` in a dock; `acme.tasks.down`, `.up`, `.first`, `.last`, `.open`, `.leave`, `.toggle`, `.focus`, `.refresh`, `.cursor` and `.rows` as commands, bound at `buf_kind` scope so `^Z` lists them and `init.ts` moves them; `acme.tasks.section`, `.action` and `.decoration` read exactly as the sidebar reads its own; the cursor published as the buffer var `cursor` and the event `acme.tasks.cursor`; and a redraw when any of it changes. The shared pickers publish kinds too — `neosh.picker`, `neosh.confirm`, `neosh.prompt` — so a key bound against one binds inside every picker at once. ### Say that something happened ```ts await neosh.event.emit("acme.indexed", { files: 412 }); neosh.event.on("acme.indexed", (e) => { /* e.data, e.from */ }); ``` Broadcast, plugin-defined, and with no reply by construction — an emitter that could be blocked is an emitter with every listener on its critical path. When you need an answer, register a command or read a contribution point. `from` is stamped by the host and cannot be forged. The workspace's own events travel the same way, `from: "neosh"` — Neovim's autocmds: | event | data | |---|---| | `neosh.ready` | once every plugin has loaded, and again after a reload | | `neosh.win.enter`, `neosh.win.leave`, `neosh.win.open` | `{ win, buf, kind }` | | `neosh.win.close` | `{ win }` | | `neosh.cursor` | `{ win, row, col }` | | `neosh.mode` | `{ mode }` | | `neosh.viewport` | `{ win, width, height }` — the one size only the frontend knows | `event.on(name, cb, { kind: "neosh.sidebar" })` keeps only the events about one kind. The host's `focus.onChange`, `onViewAttached` and `onShutdown` are the same facts as listeners. ### Colour a window, or ship a theme ```ts // Your own groups: `default` is `:hi default` — define only if nobody has, so init.ts wins // whichever of you loaded first. A group is yours until you reset it or your plugin unloads, // when it goes back to the theme's or away. await neosh.hl.define("Acme.Due", { link: "Status.Unread" }, { default: true }); const { resolved } = await neosh.hl.get("Normal"); // read a colour rather than guess at it neosh.hl.onChange(({ names }) => { /* a theme switch lists them all */ }); // One window, or every window of a kind — Neovim's `winhighlight`. Nothing else on screen changes. await neosh.hl.define("Acme.Panel", { bg: { kind: "rgb", r: 26, g: 27, b: 38 } }); await neosh.win.setHighlights({ kind: "neosh.sidebar" }, { Normal: "Acme.Panel" }); // A theme is a contribution: listed beside `dark` and `light` on `ui.theme`, applied when chosen, // gone with the plugin. Groups it does not name come from `base`. await neosh.ext.contribute("ui.theme", "gruvbox", { base: "dark", groups: { Comment: { fg: { kind: "rgb", r: 146, g: 131, b: 116 } }, "Gruv.Extra": { link: "Normal" } }, }); ``` ### A var about a buffer or a window `vars` has two more scopes, `{ scope: "buffer", buf }` and `{ scope: "window", win }` — Neovim's `b:` and `w:`. In memory only, dropped with the buffer or window, and whoever was watching is told. The answer to "a var write is a file write": what changes per keystroke is about a buffer or a window, and those are never written down. ### See what is there `neosh.ext.points()` lists every point with who reads and who writes it; `neosh.ext.plugins()` every plugin with its manifest and what became of it — loaded, held, failed; `hl.list()` every group with its owner. `plugins.list` in `^K` draws all of it: the `:checkhealth` of this workspace. --- ## Saying something to the person Three different things, and they are three calls rather than three levels of one. `MessageLevel` says how bad something is; which of these you reach for says whether the user asked for it, which is what decides where it goes and how long it lives. ```ts neosh.notify("copied /home/me/proj"); // a reply to a key they just pressed neosh.progress("acme.index", "indexing…"); // a state, replaced in place neosh.done("acme.index"); // …and taken away when it finishes await neosh.alert("acme", "index is stale", { session }); // news they did not ask for ``` **`notify` is a reply.** Feedback for the keystroke that caused it. It does not stack — a second replaces the first, because two keys pressed quickly are two keys and the answer you want is the one for the second — it lives about six seconds, and it never leaves the terminal. The commonest mistake is using it for something already on screen. If the row moved, the panel closed or the footer changed, the user can see it; saying so in the corner is the same fact twice, and a corner that usually restates something visible is a corner people stop reading. If the fact has a place on screen, put it right there instead — that is what the git plugin does with the branch segment rather than announcing every checkout. **`progress` is a state, not a message.** Keyed, so writing the same key again replaces the row and `done` takes it away. `pulling…` used to be pushed onto the message stack and so was the `up to date` that superseded it, which is how one pull drew two rows. Put `done` in a `finally` — a row nobody finishes is dropped after a minute, and relying on that is a row that lies for a minute. **`alert` is news.** Drawn in the corner *and*, if the host works out that nobody is looking, raised outside the terminal as a real notification. Whether that second part happens is the host's decision and never yours: only it knows which conversation is on screen, which terminals are attached and whether any of them has focus. Pass `session` when it is about one — that is what the "can they see this already" test is asked against. It needs `notify` in your `plugin.toml`, and is rejected with `not permitted` otherwise. Drawing in the corner stays free, like every other kind of drawing; being able to interrupt somebody who is in another application is a capability. ```toml permissions = ["notify"] ``` --- ## Keys, and the one thing you cannot do Your plugin can find out whether a provider is authenticated, and can ask for a key to be entered. It cannot read one. ```ts for (const c of await neosh.agent.credentials()) { // c.source.kind: "env" | "keychain" | "session" | "command" | "inherited" | "not_needed" | "missing" // c.accepts_key: false for a CLI login or a local endpoint — nowhere to put one } // Settles when the prompt closes. `true` means a key was stored; never the key itself. const ok = await neosh.agent.setCredential("anthropic", { replace: true }); await neosh.agent.forgetCredential("anthropic"); ``` `setCredential` does **not** open a widget of yours, and there is no `mask: true` on `prompt` for you to reach for. The host reads the keystrokes itself, because a plugin field is a buffer, a buffer is drawn, and drawing it would put the key in a `UiEvent` — across a process boundary, into whatever the frontend logs. Masking is a rendering decision; the leak is a transport one. The consequence to design around: this is the one modal you cannot re-skin. You can decide *when* to ask and what to do afterwards — the model switcher re-queries the endpoint and reopens its list — but not what the prompt looks like. --- ## Terminals A workspace can have several terminals attached and they are not copies of each other. Each is somewhere: its own conversation on screen, its own scroll position, its own composer, its own panels. What they share is the work — the conversations, the turns running in them, and everything your plugin registered. **Most plugins need to know none of this.** A window you open is routed for you: a float anchored to a window goes where that window is, a buffer only one terminal is showing names it, and otherwise it is the terminal whose key press is running. So a picker, a preview or a status panel opened in answer to a key lands where the person who pressed it is looking, without a line about views. The one thing that does have to say is a **dock**, because one that exists once exists in one terminal. Open one per view: ```ts const panels = new Map(); neosh.view.onOpen(async (view) => { // and for the terminals already here panels.set(view, await makePanel(neosh.view.at(view))); }); neosh.view.onClose((view) => { // its windows are already gone panels.delete(view); }); ``` `neosh.view.at(id)` is the whole `neosh` namespace bound to one terminal — every call on it is the call it always was, except that a window opened through it lands there. Inside a command handler you are handed one already, as the third argument: ```ts await neosh.cmd.register("mine.panel", async (args, key, here) => { const buf = await neosh.buf.create({ name: "[mine]", scratch: true }); await here.win.open(buf, "right", { size: 40 }); // this terminal }); ``` `key.view` is the terminal the key was pressed in, `neosh.view.list()` is every terminal and what each is looking at, and `session.onChange` says which one moved. If you are drawing something a person has to answer, that last one is what tells you which screen to put it on. ## Panes and tabs A terminal's main region is divided into **panes**, and a tab is a tree of them. A pane is *where*, not *what*: it owns no buffer and draws nothing. You split one, then dock windows into it — which is why splitting a chat and splitting anything else are the same two calls. ```ts const here = await neosh.pane.active(); const right = await neosh.pane.split(here, "right"); await neosh.win.open(buf, "main", { pane: right }); // fills it await neosh.win.open(field, "bottom", { pane: right, size: 3 }); // a field at its foot await neosh.pane.focus(right); ``` `win.open` takes the same four edges whether or not you name a pane — with one, the edge is measured against the pane's rectangle; without, against the screen's. That is the whole difference, and it is why nothing needed a second geometry vocabulary. **Omit `pane` for anything that belongs to the terminal rather than to one split**: a sidebar docked into a pane is a sidebar that disappears when you close that split. A window that names no pane and asks for `"main"` lands in the pane the person is *looking at* — the same rule as a float that names no view, one level down. So an existing plugin that opens a main window keeps working and keeps landing somewhere sensible. ```ts await neosh.pane.focusDir("left"); // null at the edge; there is no wrap-around await neosh.pane.resize(here, "right", 10); // weight units, not cells await neosh.pane.swap(a, b); // how a pane is moved: widths belong to the slots await neosh.pane.close(here); // rejects on the last pane of the last tab const { tabs, active } = await neosh.tab.list(); await neosh.tab.create({ title: "review", activate: false }); await neosh.tab.rename(tabs[0].id, null); // back to being named by what is in it ``` Sizes are **weights**, never cells: an even split is `WEIGHT` each, so a layout means the same thing at every terminal size and survives somebody dragging the window's corner. Splitting never nests a row inside a row — three panes side by side are one split of three — so a resize divides space you can actually see. Everything the workspace's own `^W` keys do is these calls and nothing else. `^Wv` is `pane.split(await pane.active(), "right")`; there is no private path, so a different window manager is a plugin rather than a fork. The tab strip is an ordinary buffer of kind `neosh.tabline`: bind keys against it, recolour it with `win.setHighlights`, or turn ours off and dock your own `"top"` window in its place. ## Conversations ```ts await neosh.session.list(); // what the user works in await neosh.session.list({ includeArchived: true }); // plus what they put away await neosh.session.archive(id); // reversible, keeps everything await neosh.session.archive(id, false); // back, and to the top of the list await neosh.session.close(id); // deletes the file. No undo. ``` ### Driving one you are not looking at `agent.command` does something *to* a named conversation — the same vocabulary `swarm.command` carries to another machine, pointed at one here. Omit the id for the conversation on screen. ```ts const id = await neosh.agent.command({ command: "new_session", title: "the tests" }); await neosh.agent.command({ command: "send", text: "run the suite and report" }, id!); await neosh.agent.command({ command: "interrupt" }, id!); await neosh.agent.command({ command: "set_model", instance: "anthropic", model: "…" }, id!); ``` That plus `onTurnEnd` — which says which conversation ended — is an orchestrator: fan work out over several conversations, join on their endings, and the screen never moves. `session.switch` before each message is the thing this replaced; it works, and it drags the transcript out from under whoever is reading it. ### Deciding who answers `turn.route` fires once a turn knows what it is going to say and before it knows who to say it to. A blocking hook there may re-point it, or refuse it with a reason the transcript prints. ```ts await neosh.hook.register("turn_route", (p) => { if (p.hook !== "turn_route") return { action: "continue" }; if (!p.text.startsWith("quick:")) return { action: "continue" }; return { action: "modify", payload: { ...p, selection: { instance: "local", model: "small", options: [] } }, }; }, { blocking: true }); ``` `selection` may be `null` — a conversation with no model chosen is a routable state, not an error, so a router may supply one. Needs `hooks_blocking` in the manifest. --- `archive` and `close` are different verbs on purpose. If you are writing the thing a user presses when they are done with a conversation, it is `archive`; `close` is for when they have said they mean it. Gate `close` behind `confirmDestructive` and say that archiving keeps it. Nothing archived appears in the sidebar — `session.archived` is the command that opens it, and `list({ includeArchived: true })` is how you find them yourself. A panel of your own should do the same: the list someone works in is the list of things they might switch to now. --- ## What the runtime gives you A bare `deno_core`: the ECMAScript built-ins, `console`, `queueMicrotask`, `Deno.core`, and timers. No `fetch`, no `TextEncoder`, no filesystem, no subprocess. Anything with an effect goes through the neosh API — which is what makes hooks and permissions mean something. Imports resolve for relative paths, `@neosh/api`, and its submodules. There is no package resolution; bundle third-party code into your plugin. See [testing.md](testing.md) for driving a plugin with no API key, no network and no terminal. ==================== # Canonical guide: testing a plugin (docs/testing.md) ==================== # Testing Two different questions, in order: is neosh itself working, and is the thing *you* wrote working. --- ## Trying it ```sh cargo run # uses an existing `claude` CLI login — no API key cargo run -- --list-models # everything reachable right now cargo run -- paths # where config is read from, and what exists cargo run -- init # write a starter config, then edit it ``` `cargo run` needs a real terminal. Outside one it refuses with a message telling you to use `--ui-protocol=stdio` — that is not a workaround, it is the same session with a different frontend (see below). Inside: type, `Enter` to send, `Esc` to interrupt a turn, `` to reload config. **`` gets you out** — it cancels a running turn, then clears a draft, then offers to quit on a second press; `` leaves immediately. The bottom line shows the mode, the selected model, and those keys — if you see it, neosh is running. The panel on the left is the `sidebar` plugin; `` moves into the thread list, `` hides it, `` picks a model, `` its reasoning effort, `` git status, `` a diff, `` the command palette, `` every binding there is. ## Checking neosh ```sh ./scripts/check.sh # everything CI runs — do this before committing ``` Four stages, and CI runs each on a runner of its own so a pull request waits for the slowest rather than the sum: `crates` (every crate below the binary, plus the ts-rs binding drift check and what `cargo package` would ship), `binary` (the neosh binary's own tests, the suites that drive it, and a freshly scaffolded config type-checked against its own emitted types), `screen` (the bundled plugins, on screen, one whole neosh per test) and `web` (`tsc` over the plugin API, the example plugin and the bundled plugins, and what each of them publishes). The `tsc` steps matter because a Rust type change that silently breaks every plugin's types is exactly the failure this project is built to avoid — and the bundled-plugin step is what keeps "the sidebar is just a plugin" from quietly becoming false. ```sh ./scripts/check.sh crates # one stage ./scripts/check.sh screen 1/3 # one slice of one, the way a CI runner takes it ``` Tests run under [`cargo nextest`](https://nexte.st) when it is installed — one process per test, and a slice needs it — and under `cargo test` when it is not. Either way the pty suites run on half the machine's cores, because a workspace per test starved of CPU is a different timeout on every run; `NEOSH_TEST_THREADS` overrides that. Narrower loops: ```sh cargo test --workspace # ~390 tests, no network, no API key cargo test -p neosh-core # buffer/extmark/keymap/option invariants cargo test -p neosh --test hello_plugin # the six DoD items, through the real binary cargo test -p neosh --test user_config # config, reload, trust, timers, end to end cargo test -p neosh --test builtin_plugins # the sidebar and switchers, through the binary cargo test -p neosh --test sessions # conversations, projects, worktrees cargo test -p neosh --test approvals # the permission prompt, end to end cargo test -p neosh-vcs # git parsers, plus a real repository cargo test -p neosh-script # module loading, transpile, timer heap ``` Everything runs offline against recorded fixtures. Nothing needs credentials. ## Debugging a session ```sh NEOSH_LOG=/tmp/neosh.log NEOSH_LOG_LEVEL=debug cargo run ``` Logs never go to stdout — stdout is the UI protocol, and a stray log line would corrupt the stream a frontend is parsing. `neosh.log.info(...)` from a plugin lands here, attributed: ``` INFO neosh_core::editor: hello from the plugin log plugin=greeter ``` `--clean` starts with no config, no plugins and no trust decisions. When something is broken, this is the first thing to try: it tells you in one step whether the problem is neosh or your config. --- ## Testing what you wrote `--ui-protocol=stdio` turns the session into JSON lines on stdout and stdin, and `--mock-script` replays a recorded model turn instead of calling one. Together they make a plugin testable with no API key, no network, and no terminal — which is also exactly how neosh tests itself. ### 1. A recorded turn One JSON-encoded `ProviderEvent` per line; a **blank line separates turns**, so one file can drive a whole tool loop. This one calls your tool, then replies: ```jsonl {"type": "message_start", "model": "mock", "usage": {}} {"type": "block_start", "index": 0, "block": {"kind": "tool_use", "id": "c1", "name": "greet"}} {"type": "tool_input_delta", "index": 0, "partial_json": "{\"who\":\"world\"}"} {"type": "block_stop", "index": 0} {"type": "message_delta", "stop_reason": {"kind": "tool_use"}, "usage": {}} {"type": "message_stop"} {"type": "message_start", "model": "mock", "usage": {}} {"type": "block_start", "index": 0, "block": {"kind": "text"}} {"type": "text_delta", "index": 0, "text": "done"} {"type": "block_stop", "index": 0} {"type": "message_delta", "stop_reason": {"kind": "end_turn"}, "usage": {}} {"type": "message_stop"} ``` To record a real one instead of hand-writing it, run with `NEOSH_LOG_LEVEL=trace` and lift the provider events out of the log. There are two ready-made ones under `crates/neosh/tests/fixtures/`. `markdown_turn.jsonl` is an answer with a heading, a list and a fenced block in it — enough shape that "the block under the cursor" is a real question. `wrapping_turn.jsonl` is six paragraphs of prose long enough to **wrap**, which the first one deliberately is not: a buffer row that draws as four screen rows is the case every piece of caret and page arithmetic gets wrong, and it is invisible in a fixture whose lines all fit. Use it for anything about where the cursor is. ### 2. Run it ```sh neosh --ui-protocol=stdio --clean \ --plugin-dir ./plugins \ --mock-script ./turn.jsonl --model mock/mock ``` `--clean` is important: without it your own config loads too, and a test that passes because of something in your `init.ts` is not a test. ### 3. Drive it **Wait for output, never for a duration.** This is the mistake to avoid — driving input before your plugin has finished activating produces `no such tool`, intermittently, and only on a loaded machine or in CI. Have your plugin announce itself (`neosh.notify("myplugin ready")`) and wait for that. A harness small enough to copy: ```python import json, subprocess, threading, queue, time proc = subprocess.Popen( ["neosh", "--ui-protocol=stdio", "--clean", "--plugin-dir", "./plugins", "--mock-script", "./turn.jsonl", "--model", "mock/mock"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) events, q = [], queue.Queue() threading.Thread(target=lambda: [q.put(l) for l in proc.stdout], daemon=True).start() def send(o): proc.stdin.write(json.dumps(o) + "\n"); proc.stdin.flush() def texts(): out = [] for e in events: if e.get("type") == "buffer_lines": out += [x["text"] for x in e["lines"] if x["text"]] elif e.get("type") == "message": out.append(e["text"]) return out def wait_for(needle, budget=15): end = time.time() + budget while time.time() < end: if any(needle in t for t in texts()): return try: events.append(json.loads(q.get(timeout=end - time.time()))) except Exception: break raise AssertionError(f"never saw {needle!r}; saw {texts()}") def key(c): send({"type": "key", "key": {"code": {"kind": "char", "c": c}, "mods": {}}}) send({"type": "ready", "width": 80, "height": 24}) wait_for("greeter ready") # your plugin said so — not a sleep for c in "hi": key(c) send({"type": "key", "key": {"code": {"kind": "enter"}, "mods": {}}}) wait_for("turn finished") assert not any("no such tool" in t for t in texts()) ``` Reading a blocking stream on a thread rather than inline is not incidental: a blocking read ignores your timeout entirely, so a stalled host hangs the test forever instead of failing with a transcript. (neosh's own test harness had this bug.) ### 4. Assert on what a user would see The stdio stream is the same `UiEvent` sequence the terminal renders, so assert on rendered text rather than reaching into internals. The events you will want: | event | | |---|---| | `buffer_lines` | the only buffer mutation. `lines[].text` plus `lines[].marks` — text and extmarks together, so they cannot desync | | `message` | transient notifications (`neosh.notify`) | | `window_opened` / `window_closed` | includes `layout.kind == "float"` | | `flush` | end of a coalesced frame | Input you can send: `ready`, `key`, `paste`, `viewport_changed`, `resize`, `command`. A key is `{"type":"key","key":{"code":{"kind":"char","c":"x"},"mods":{"ctrl":true}}}`; other `kind`s include `enter`, `esc`, `backspace`, `up`, `down`, `f` (with `n`). `{"type":"command","name":"git.branch.new"}` runs a command by name. Prefer it over synthesising a keystroke: a command with no default binding has no key to synthesise, and one that does breaks the moment a user rebinds it. This is the same event a menu or a palette entry in a real frontend sends. ### Type checking ```sh cd ~/.config/neosh && npx tsc --noEmit # your config cd my-plugin && npx tsc --noEmit # your plugin ``` The types come from the binary itself (`neosh init` writes them, and startup refreshes them when they drift), so this checks against the neosh you are actually running rather than a published package that may be a version behind. TypeScript is **transpiled, not type-checked** at load — types are erased and a type error becomes a runtime surprise. Run `tsc` yourself; it is the only thing that catches them. ### Reload instead of restart While iterating, `` re-reads config, reloads every plugin, and resets settings you deleted back to their defaults. A config that stops parsing leaves the running one alone and tells you why, so a typo costs you nothing. --- ## Known sharp edges - **A plugin that throws during `activate` is reported and skipped.** Check the log, or the message line — it will not stop neosh from starting. - **`--clean` and `--config-dir `** are how you isolate a test from your real config. Every XDG root is also overridable: `NEOSH_CONFIG_DIR`, `NEOSH_DATA_DIR`, `NEOSH_STATE_DIR`, `NEOSH_CACHE_DIR`. - **Project config in `.neosh/` needs `neosh trust`** before its code runs. In a test, either avoid it or point `NEOSH_STATE_DIR` at a scratch directory and run `neosh trust` first. - **The runtime has no `fetch` or filesystem access.** If your plugin needs either, it goes through the neosh API, and that is deliberate — see [config.md](config.md#what-the-runtime-gives-you). ==================== # The @neosh/api surface (plugins/api/src/index.ts) ==================== ```ts /** * `@neosh/api` — the entire plugin surface. * * This file is both the implementation and the types. It is embedded into the host binary and * transpiled at load, and it is what plugin authors type-check against, so the two cannot drift * apart — there is no second copy to forget to update. * * Everything here is built on the wire types in `./generated/`, which are emitted from the Rust * side by ts-rs and drift-checked in CI. Nothing in this file reaches the host except as an * `ApiCall`, which means an out-of-process plugin in another language has exactly this surface. */ import type { Activity } from "./generated/Activity"; import type { AttachmentInfo } from "./generated/AttachmentInfo"; import type { ApiCall } from "./generated/ApiCall"; import type { ApiError } from "./generated/ApiError"; import type { ApiOk } from "./generated/ApiOk"; import type { ApiResponse } from "./generated/ApiResponse"; import type { BranchInfo } from "./generated/BranchInfo"; import type { Direction } from "./generated/Direction"; import type { PaneId } from "./generated/PaneId"; import type { TabId } from "./generated/TabId"; import type { TabInfo } from "./generated/TabInfo"; import type { BufferId } from "./generated/BufferId"; import type { Capability } from "./generated/Capability"; import type { CommitInfo } from "./generated/CommitInfo"; import type { CommandEntry } from "./generated/CommandEntry"; import type { AgentCommand } from "./generated/AgentCommand"; import type { AgentState } from "./generated/AgentState"; import type { AgentSummary } from "./generated/AgentSummary"; import type { Contribution } from "./generated/Contribution"; import type { AccountKind } from "./generated/AccountKind"; import type { Brand } from "./generated/Brand"; import type { CredentialInfo } from "./generated/CredentialInfo"; import type { CredentialSource } from "./generated/CredentialSource"; import type { CursorMotion } from "./generated/CursorMotion"; import type { CursorShape } from "./generated/CursorShape"; import type { SelectShape } from "./generated/SelectShape"; import type { ScrollAmount } from "./generated/ScrollAmount"; import type { DiffTarget } from "./generated/DiffTarget"; import type { Dock } from "./generated/Dock"; import type { Gravity } from "./generated/Gravity"; import type { Hint } from "./generated/Hint"; import type { ExtmarkId } from "./generated/ExtmarkId"; import type { FileChange } from "./generated/FileChange"; import type { FileState } from "./generated/FileState"; import type { ExtmarkInfo } from "./generated/ExtmarkInfo"; import type { ExtmarkOpts } from "./generated/ExtmarkOpts"; import type { FloatConfig } from "./generated/FloatConfig"; import type { HighlightDef } from "./generated/HighlightDef"; import type { HighlightEntry } from "./generated/HighlightEntry"; import type { HighlightSpec } from "./generated/HighlightSpec"; import type { HlTarget } from "./generated/HlTarget"; import type { HookName } from "./generated/HookName"; import type { HookOutcome } from "./generated/HookOutcome"; import type { HookPayload } from "./generated/HookPayload"; import type { InstanceConfig } from "./generated/InstanceConfig"; import type { KeyContext } from "./generated/KeyContext"; import type { KeymapEntry } from "./generated/KeymapEntry"; import type { KeymapScope } from "./generated/KeymapScope"; import type { MessageLevel } from "./generated/MessageLevel"; import type { Mode } from "./generated/Mode"; import type { ModelEntry } from "./generated/ModelEntry"; import type { ModelInfo } from "./generated/ModelInfo"; import type { ModelSelection } from "./generated/ModelSelection"; import type { ModelTier } from "./generated/ModelTier"; import type { NamespaceId } from "./generated/NamespaceId"; import type { OptionChoice } from "./generated/OptionChoice"; import type { OptionEntry } from "./generated/OptionEntry"; import type { OptionSelection } from "./generated/OptionSelection"; import type { OptionSpec } from "./generated/OptionSpec"; import type { OptionType } from "./generated/OptionType"; import type { OptionValue } from "./generated/OptionValue"; import type { PermissionDecision } from "./generated/PermissionDecision"; import type { PermissionMode } from "./generated/PermissionMode"; import type { PermissionOption } from "./generated/PermissionOption"; import type { PermissionOptionKind } from "./generated/PermissionOptionKind"; import type { QuestionAnswer } from "./generated/QuestionAnswer"; import type { QuestionOption } from "./generated/QuestionOption"; import type { UserQuestion } from "./generated/UserQuestion"; import type { PluginEvent } from "./generated/PluginEvent"; import type { PointInfo } from "./generated/PointInfo"; import type { PluginInfo } from "./generated/PluginInfo"; import type { PluginManifest } from "./generated/PluginManifest"; import type { Pricing } from "./generated/Pricing"; import type { QuotaCredits } from "./generated/QuotaCredits"; import type { QuotaSample } from "./generated/QuotaSample"; import type { QuotaSeverity } from "./generated/QuotaSeverity"; import type { QuotaSnapshot } from "./generated/QuotaSnapshot"; import type { InstallMethod } from "./generated/InstallMethod"; import type { UpdateOutcome } from "./generated/UpdateOutcome"; import type { UpdateProgress } from "./generated/UpdateProgress"; import type { UpdateStatus } from "./generated/UpdateStatus"; import type { QuotaSource } from "./generated/QuotaSource"; import type { QuotaWindow } from "./generated/QuotaWindow"; import type { UsageBucket } from "./generated/UsageBucket"; import type { UsageHistory } from "./generated/UsageHistory"; import type { UsageResolution } from "./generated/UsageResolution"; import type { UsageScanSource } from "./generated/UsageScanSource"; import type { CostBasis } from "./generated/CostBasis"; import type { DriverCommand } from "./generated/DriverCommand"; import type { PlanState } from "./generated/PlanState"; import type { PlanStep } from "./generated/PlanStep"; import type { ProviderEvent } from "./generated/ProviderEvent"; import type { TaskId } from "./generated/TaskId"; import type { TaskStatus } from "./generated/TaskStatus"; import type { ProviderOptionDescriptor } from "./generated/ProviderOptionDescriptor"; import type { Message } from "./generated/Message"; import type { Rect } from "./generated/Rect"; import type { RepoInfo } from "./generated/RepoInfo"; import type { RepoStatus } from "./generated/RepoStatus"; import type { SessionId } from "./generated/SessionId"; import type { SessionInfo } from "./generated/SessionInfo"; import type { StatusAlign } from "./generated/StatusAlign"; import type { StatusSegment } from "./generated/StatusSegment"; import type { StopReason } from "./generated/StopReason"; import type { SurfaceCell } from "./generated/SurfaceCell"; import type { TextEdit } from "./generated/TextEdit"; import type { SurfaceId } from "./generated/SurfaceId"; import type { ToolCall } from "./generated/ToolCall"; import type { ToolDef } from "./generated/ToolDef"; import type { ToolResult } from "./generated/ToolResult"; import type { ImageFile } from "./generated/ImageFile"; import type { TurnRequest } from "./generated/TurnRequest"; import type { Usage } from "./generated/Usage"; import type { NodeCapabilities } from "./generated/NodeCapabilities"; import type { NodeId } from "./generated/NodeId"; import type { LinkState } from "./generated/LinkState"; import type { NodeInfo } from "./generated/NodeInfo"; import type { ProjectKey } from "./generated/ProjectKey"; import type { RemoteProject } from "./generated/RemoteProject"; import type { StreamEvent } from "./generated/StreamEvent"; import type { SwarmAgent } from "./generated/SwarmAgent"; import type { SwarmNode } from "./generated/SwarmNode"; import type { SwarmStranger } from "./generated/SwarmStranger"; import type { VarScope } from "./generated/VarScope"; import type { ViewId } from "./generated/ViewId"; import type { ViewInfo } from "./generated/ViewInfo"; import type { Viewport } from "./generated/Viewport"; import type { WindowId } from "./generated/WindowId"; import type { WindowInfo } from "./generated/WindowInfo"; import type { WindowLayout } from "./generated/WindowLayout"; import type { ChecksState } from "./generated/ChecksState"; import type { PullRequest } from "./generated/PullRequest"; import type { PullState } from "./generated/PullState"; import type { WorktreeInfo } from "./generated/WorktreeInfo"; export type { AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo, AgentCommand, AgentState, AgentSummary, Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Direction, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig, PaneId, TabId, TabInfo, Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext, AttachmentInfo, KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId, OptionChoice, OptionEntry, OptionSelection, OptionSpec, OptionType, OptionValue, DriverCommand, PlanState, PlanStep, TaskId, TaskStatus, Message, PermissionDecision, PermissionMode, PermissionOption, PermissionOptionKind, PluginEvent, PluginInfo, PluginManifest, PointInfo, Pricing, ProviderEvent, ProviderOptionDescriptor, QuestionAnswer, QuestionOption, UserQuestion, CostBasis, QuotaCredits, QuotaSample, QuotaSeverity, QuotaSnapshot, QuotaSource, QuotaWindow, InstallMethod, UpdateOutcome, UpdateProgress, UpdateStatus, UsageBucket, UsageHistory, UsageResolution, UsageScanSource, Rect, RepoInfo, RepoStatus, ScrollAmount, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason, SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage, ImageFile, NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent, LinkState, SwarmAgent, SwarmNode, SwarmStranger, VarScope, ViewId, ViewInfo, Viewport, WindowId, WindowInfo, WindowLayout, ChecksState, PullRequest, PullState, WorktreeInfo, }; // --------------------------------------------------------------------------- // Columns // --------------------------------------------------------------------------- /** * UTF-8 byte length of a string. * * Every column in this API is a **UTF-8 byte offset**, matching Neovim and for the same reason: it * keeps display-width math in exactly one place, the frontend. JavaScript's `.length` is UTF-16 * code units, which agrees with bytes only for ASCII — so a highlight placed with `.length` on a * line containing an emoji or any CJK lands in the wrong column, or inside a character. */ export function byteLength(s: string): number { let n = 0; for (const ch of s) { const c = ch.codePointAt(0) ?? 0; n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; } return n; } /** * Byte offset of each code point in `s`, plus one final entry holding the total length. * * `byteOffsets(s)[i]` is the column to pass for the `i`th code point, so a match found with * `Array.from(s)` can be turned into marks without measuring the prefix again per position. */ export function byteOffsets(s: string): number[] { const out: number[] = []; let n = 0; for (const ch of s) { out.push(n); const c = ch.codePointAt(0) ?? 0; n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; } out.push(n); return out; } // --------------------------------------------------------------------------- // Transport // --------------------------------------------------------------------------- /** The ops the host installs. Everything else is built on these. */ interface CoreOps { op_neosh_send(msg: unknown): void; op_neosh_next(): Promise; op_neosh_width(text: string): number; op_neosh_clip(text: string, columns: number): string; } declare const Deno: { core: { ops: CoreOps } }; /** * How many terminal columns a string occupies. * * Use this for any layout with a column in it. JavaScript offers `String.length` (UTF-16 units) and * `Array.from(s).length` (code points), and both are wrong for the text a model produces: `"日本"` * is 2 code points and **4** columns, `"👋🏽"` is 2 code points and **2**, `"é"` may be 2 code points * and **1**. Padding with either draws a ragged rule the first time a CJK model name appears. * * Synchronous — it is an op, not a host call, so calling it per row costs nothing. It uses the same * measurement the renderer does, so a plugin and the frontend agree by construction. */ export function width(text: string): number { return Deno.core.ops.op_neosh_width(text); } /** Truncate to at most `columns` columns, cutting on a grapheme boundary rather than mid-character. */ export function clipToWidth(text: string, columns: number): string { return Deno.core.ops.op_neosh_clip(text, Math.max(0, Math.floor(columns))); } /** Pad on the right to exactly `columns`, clipping if it is already wider. */ export function padToWidth(text: string, columns: number): string { const clipped = clipToWidth(text, columns); return clipped + " ".repeat(Math.max(0, columns - width(clipped))); } /** Thrown when the host refuses a call. Carries the structured reason. */ export class NeoshError extends Error { constructor(readonly cause_: ApiError) { super(describeError(cause_)); this.name = "NeoshError"; } } function describeError(e: ApiError): string { switch (e.kind) { case "not_found": return `not found: ${e.what}`; case "invalid_argument": return `invalid argument: ${e.message}`; case "denied": return `denied: ${e.reason}`; case "not_permitted": return `not permitted: ${e.capability}`; case "busy": return `busy: ${e.message}`; case "internal": return `internal error: ${e.message}`; } } let seq = 0; const pending = new Map void; reject: (e: unknown) => void }>(); function send(msg: unknown): void { Deno.core.ops.op_neosh_send(msg); } /** * Issue a call that wants an answer. * * Calls from one plugin are applied by the host in the order they were issued, so a caller that * fires several mutations without awaiting cannot race itself. */ function call(plugin: string, c: ApiCall, view?: ViewId): Promise { const id = `${plugin}#${++seq}`; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ type: "plugin", plugin, msg: { type: "call", id, call: c, view } }); }); } /** Fire-and-forget. Used on the streaming path so appending a token is not round-trip bound. */ function notify(plugin: string, c: ApiCall, view?: ViewId): void { send({ type: "plugin", plugin, msg: { type: "notify", call: c, view } }); } function settle(id: string, response: ApiResponse): void { const p = pending.get(id); if (!p) return; pending.delete(id); if (response.status === "ok") p.resolve(response.value); else p.reject(new NeoshError(response.error)); } /** Narrow an `ApiOk` to the variant a call is documented to return. */ function expect(v: ApiOk, kind: K): Extract { if (v.ok !== kind) { throw new Error(`host returned ${v.ok} where ${kind} was expected`); } return v as Extract; } // --------------------------------------------------------------------------- // Public surface // --------------------------------------------------------------------------- export interface Disposable { dispose(): void; } export interface PluginContext { readonly neosh: Neosh; readonly pluginId: string; readonly config: unknown; /** Anything pushed here is disposed when the plugin unloads. */ readonly subscriptions: Disposable[]; } /** A plugin's entry module exports this. */ export type Activate = (ctx: PluginContext) => void | Promise; export interface VirtText { text: string; hlGroup?: string; } export interface MarkOptions { endCol?: number; hlGroup?: string; /** * A background for the whole rendered row, not just the bytes the mark covers. * * It sits *under* every ranged `hlGroup` on the row rather than competing with them, so a row can * be banded — selected, changed, at fault — and the text on it keeps whatever colour said what it * was. `hlGroup` across the same span would replace that colour instead, which is how a selected * row ends up the only one that has stopped saying anything. */ lineHlGroup?: string; virtText?: VirtText[]; virtTextPos?: ExtmarkOpts["virt_text_pos"]; onDelete?: ExtmarkOpts["on_delete"]; priority?: number; /** * The row is a picture from this column on, and this is where it is. * * The row's text from that column is what a terminal that cannot draw pictures shows — its * name — and one that can (kitty, Ghostty, WezTerm, Konsole, tmux with passthrough) draws the * picture there instead, as many screen rows tall as it decides. Still one row of the buffer * however tall it draws, exactly as a row that wraps is. The path is on the workspace's own * disk; a picture the agent read or you attached is already there, under the state directory. */ image?: { path: string; mediaType: string }; } /** One mark of a {@link DrawnRow}, positioned on that row. */ export interface DrawnMark { /** * UTF-8 byte offset into the row's text — the unit every column on the wire uses. Build it with * {@link byteLength}, never `.length`. */ col: number; opts?: MarkOptions; } /** One row of a repaint: its text, and everything drawn on it. */ export interface DrawnRow { text: string; marks?: DrawnMark[]; } export interface FloatOptions { anchor?: FloatConfig["anchor"]; offset?: { row: number; col: number }; width?: FloatConfig["width"]; height?: FloatConfig["height"]; z?: number; border?: FloatConfig["border"]; borderHl?: string; title?: string; /** * A strip on the bottom border: what the keys here do. * * On the border rather than in the buffer, because a key strip written as a row of content is one * that scrolls away exactly when it is wanted, and is the first thing clipped on a terminal too * short for the panel. It costs no content row and cannot be scrolled off. * * Clipped to the border's width like the title, so it is for a legend and not for prose. */ footer?: string; closeOnBlur?: boolean; focusable?: boolean; /** * Take the keyboard while this float has focus. * * Global bindings stop resolving: `^N`, `^T`, `^G` and the rest do nothing until it closes, * instead of opening a second panel behind the first. Your own bindings still work — window, * buffer and kind scopes are all nearer than global — and so does anything in * `ui.modal_escape_keys` (`^Q` and `^R` by default), so a panel that forgets to bind a way out * is never a terminal somebody has to kill. A key nothing claimed is swallowed rather than * falling through to the composer. * * For a panel you are meant to answer before doing anything else: a question, a confirmation, a * control sheet. Not for a hint or a hover card. */ modal?: boolean; /** * This panel is a place you can move in when it does not fit. * * You ask for a height and get whatever there is: `{ kind: "max", n: 30 }` on a twenty-row * terminal is ten rows of panel and twenty rows of content drawn nowhere, with no key pointed at * them and nothing on screen to say they exist. Set this and the workspace's scroll keys resolve * here — `j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, the arrows and the paging keys — along with the * mouse wheel, and a bar appears down the right border while anything is hidden. * * They are ordinary bindings on `{ kind: "buf_kind", name: "neosh.scroll" }`, so `^Z` lists them * and `init.ts` moves them. That scope sits *below* your buffer's own kind, so a key you bind on * your panel is still yours — and above `global`, so it works under `modal` too. * * For a panel of rows you read: a key sheet, a diff, a status, a help screen. **Not** for one with * a cursor of its own — a picker, a list — which already scrolls to keep the cursor on screen and * whose filter would lose `j` and `G` to this. */ scroll?: boolean; } export interface Neosh { readonly version: number; readonly buf: BufferApi; readonly win: WindowApi; /** The panes of the main region. See {@link PaneApi}. */ readonly pane: PaneApi; /** The tabs each terminal has. See {@link TabApi}. */ readonly tab: TabApi; readonly float: FloatApi; readonly edit: EditApi; readonly ns: NamespaceApi; readonly hl: HighlightApi; readonly ui: RawCellApi; readonly cmd: CommandApi; readonly keymap: KeymapApi; readonly focus: FocusApi; readonly agent: AgentApi; readonly tool: ToolApi; readonly hook: HookApi; readonly provider: ProviderApi; readonly git: GitApi; readonly gen: GenApi; readonly session: SessionApi; readonly view: ViewApi; readonly status: StatusApi; readonly hint: HintApi; readonly opt: OptionApi; readonly state: StateApi; readonly vars: VarApi; /** The directories somebody works in. See {@link ProjectApi}. */ readonly project: ProjectApi; readonly ext: ExtensionApi; readonly event: EventApi; readonly swarm: SwarmApi; readonly quota: QuotaApi; /** Whether there is a newer neosh, and becoming it. */ readonly update: UpdateApi; readonly rtp: RuntimePathApi; readonly path: PathApi; readonly timer: TimerApi; readonly log: Logger; /** * Say something in the corner, as a reply to a key the user just pressed. * * The default and by far the commonest case: feedback for a keystroke. It does not stack — a * second one replaces the first, because two keys pressed quickly are two keys and the reply * you want is the one for the second — it lives about six seconds, and it never leaves the * terminal. * * What it is *not* for is the thing the user can already see. `favourited ~/proj` next to a row * that just grew a pin is the same fact printed twice, and a corner that is usually saying * something you did not need is a corner people stop reading. See * {@link Neosh.progress} and {@link Neosh.alert} for the two things that are not this. */ notify(message: string, level?: MessageLevel): void; /** * Say what is happening, in a row that gets replaced rather than stacked. * * Keyed: writing the same key again replaces the row, and {@link Neosh.done} takes it away. This * is what `pulling…` should have been — it was pushed onto the message stack, and so was the * `up to date` that superseded it, which is how one pull drew two rows. * * A row nobody finishes is dropped after a minute, so a plugin that crashes mid-operation cannot * leave a permanent claim on screen. Relying on that is a row that lies for up to a minute. */ progress(key: string, message: string): void; /** Take a progress row off, because the thing it was about has finished. */ done(key: string): void; /** * News: something happened that the user did not ask for. * * Drawn in the corner like a message, and — if the host works out that nobody is looking — * raised outside the terminal as well, as an escape sequence the terminal turns into a real * notification. Whether that happens is the host's decision and not yours: only it knows which * conversation is on screen, which terminals are attached and whether any has focus. * * Needs `notify` in `plugin.toml`, because a plugin that can interrupt somebody who is in * another application is a capability rather than a way of drawing. Rejected with * `not permitted` otherwise. * * @param session Which conversation this is about, if it is about one. The test for "can they * see this already" is asked against it; an alert about no conversation is never on screen. */ alert( title: string, message: string, opts?: { level?: MessageLevel; session?: SessionId }, ): Promise; /** Ask the host whether a side effect is allowed. */ permit(capability: Capability): Promise; /** * Ask the *person* a question, and wait for the answer. * * Not a permission and not a picker. A permission asks whether something may happen and policy * can answer it without waking anybody; a picker is a list you opened. This is the panel an agent * gets when it asks you which library, which approach, which of these to enable — several * questions in one sitting, some of them taking more than one answer, any of them answerable with * something nobody listed. * * Whatever serves the `ask_user` hook draws it, so your question and the agent's look the same * and a plugin that replaces the panel replaces both. * * `null` is *nobody answered* — dismissed, or timed out. Not an error: treating it as one means * reporting a failure every time somebody presses ``. */ ask(questions: UserQuestion[]): Promise; /** * A terminal attached to a workspace that was already running. Only a plugin drawing raw cells * needs this — the core forwards a surface's cells and keeps no copy, so they have to be painted * again; everything in a buffer is republished without help. */ onViewAttached(cb: () => void): Disposable; /** * The workspace is stopping. `deactivate` is called after this; both are bounded, so say your * goodbye quickly. Reload is not this: a reloaded plugin gets `deactivate` and nothing here. */ onShutdown(cb: () => void): Disposable; /** * What the agent is allowed to do without asking, and how to change it. * * `setMode` lasts for this session only. A mode switched on to get through one task should not * still be on next week, and writing it to a file is exactly how that happens — so it is not * written to one. */ readonly permission: PermissionApi; } export interface BufferApi { /** * `kind` is what this buffer *is* — `neosh.sidebar`, `acme.tasks`. Say it if anything you draw is * a panel somebody else might want to extend: it is what `keymap.set(..., { scope: { kind } })` * binds against and what `win.list()` reports, and it costs one argument. Reverse domain by * convention. */ create(opts?: { name?: string; scratch?: boolean; kind?: string }): Promise; lineCount(buf: BufferId): Promise; /** * `end` is exclusive. Negative indices are `len + 1 + i`, as in Neovim's `nvim_buf_set_lines`: * **`-1` is one past the last line**, so `getLines(buf, 0, -1)` is the whole buffer. To address * the last line itself, use `-2, -1`. */ getLines(buf: BufferId, start: number, end: number): Promise; /** * Range replacement. There is no whole-document write, by design — streaming a response must not * resend the document once per token. * * `setLines(buf, 0, -1, lines)` replaces the buffer; `setLines(buf, -1, -1, lines)` appends. */ setLines(buf: BufferId, start: number, end: number, lines: string[]): Promise; /** * Replace a range of rows **and** `ns`'s marks on them, in one call. What a panel should use to * draw itself. * * The atomic form of `setLines` + `ns.clear` + a `ns.mark` per mark. That sequence is correct at * rest and wrong in flight: each call is a round trip, the frontend draws on a ~16 ms deadline * that knows nothing about how far through a repaint you are, and a frame landing after the clear * draws every row with no marks at all — in `Normal`, which is near-white. A dim panel redrawing * ten times a second flashes bright. This has no halfway state to observe, and costs one round * trip instead of one per mark. * * Indices resolve as {@link setLines}'s do, and the clear covers exactly the rows written — so a * partial repaint leaves the rest of the panel alone. Other namespaces are untouched, which is * what lets an overlay survive the panel under it redrawing. */ render( buf: BufferId, ns: NamespaceId, start: number, end: number, rows: DrawnRow[], ): Promise; /** Append to the final line without resending it. The streaming fast path. */ appendText(buf: BufferId, text: string): Promise; setName(buf: BufferId, name: string): Promise; /** Declare — or with `null`, withdraw — what this buffer is. See `create`. */ setKind(buf: BufferId, kind: string | null): Promise; kind(buf: BufferId): Promise; onChange( buf: BufferId, cb: (e: { buf: BufferId; start: number; oldEnd: number; newEnd: number }) => void, ): Promise; } /** * The panes of the main region, and the tabs they live in. * * A pane is *where*, not *what*: it owns no buffer and draws nothing. You split one, then dock * windows into it with {@link WindowApi.open} — which is why splitting a chat and splitting a * terminal are the same two calls. * * Everything here is what the workspace's own `` keys are built from. There is no private * path: `v` is `pane.split(await pane.active(), "right")` and nothing else, so a plugin that * wants a different window manager can have one without forking anything. */ export interface PaneApi { /** The pane this terminal's keys belong to. */ active(view?: ViewId): Promise; /** * Divide a pane in two, and answer with the new one. * * The new pane is *empty* — nothing is drawn in it until something is docked into it. Focus does * not move either: a plugin splitting to show you something and a person pressing a key to go * and work there are different intentions, and only the second one wants the keyboard. */ split(pane: PaneId, dir: Direction): Promise; /** * Close a pane and every window docked in it. * * Rejects on the last pane of the last tab: a terminal with nowhere to draw has no key that * would bring a pane back. */ close(pane: PaneId): Promise; focus(pane: PaneId): Promise; /** * Move the keyboard to the nearest pane in a direction, and answer with it — or `null` at the * edge of the layout, which is an ordinary outcome of holding a key down rather than an error. * There is deliberately no wrap-around. */ focusDir(dir: Direction, view?: ViewId): Promise; /** * Move the boundary on a pane's `dir` side. `delta` is in weight units — an even split is * {@link WEIGHT} each — so a resize means the same thing at every terminal size and survives the * terminal being resized under it. A pane with no boundary on that side is a no-op. */ resize(pane: PaneId, dir: Direction, delta: number): Promise; /** * Exchange two panes' places, keeping what is in each. The weights belong to the slots, so * swapping into the wide one is how you make a pane wide. */ swap(pane: PaneId, withPane: PaneId): Promise; /** * Send a pane to the far edge of its tab — the counterpart to {@link PaneApi.focusDir}, which * moves the keyboard rather than the pane. It arrives with an even share rather than the size it * had: it is among different neighbours now, and carrying the old number over would make it the * odd one out for a reason nobody could see. */ moveToEdge(pane: PaneId, dir: Direction): Promise; /** Give every pane of the active tab an equal share, at every level. */ equalize(view?: ViewId): Promise; } /** * The tabs of one terminal. Each is a title, a tree of panes, and the conversation it belongs to. * * **A tab belongs to a conversation, and the bar shows one conversation's tabs** — that is * {@link TabInfo.group}, and it is why a shell opened while reading one conversation is not still * on the bar after you have switched to another. The tabs of the conversations you are not in are * not closed and nothing in them stops; they are off the strip until you go back. * * Which splits these calls in two. {@link TabApi.list} answers with *every* tab of a terminal, * whatever bar it is on, because "what is open here" is a real question — and {@link TabApi.step}, * {@link TabApi.move} and the numbers on the strip all count the tabs of one conversation, because * a key about where you are looking must not change what you are working on. */ export interface TabApi { /** * Every tab of a terminal, its panes, and which one is on screen. * * All of them, including the ones on other conversations' bars: filter by {@link TabInfo.group} * for what is drawn, which is the tabs sharing the active tab's group. */ list(view?: ViewId): Promise<{ tabs: TabInfo[]; active: TabId }>; /** * Open a tab with one empty pane. `activate` goes to it — `false` is for putting work somewhere * without moving the screen out from under whoever is reading it. * * On the bar you are looking at unless `group` says otherwise: a tab opened while reading a * conversation is that conversation's, which is what a new tab almost always wants. */ create(opts?: { title?: string; activate?: boolean; view?: ViewId; group?: string; }): Promise; /** * Move a tab to another conversation's bar, or to nobody's with `null`. See * {@link TabInfo.group}. * * It keeps its panes, its windows and anything running in them — this says which bar it is on * and nothing else, so moving the tab you are *in* changes which other tabs are beside it. */ group(tab: TabId, group: string | null): Promise; /** Close a tab and every pane in it. Rejects on a conversation's last tab. */ close(tab: TabId): Promise; select(tab: TabId): Promise; /** * Go `delta` tabs along, wrapping — a tab bar is a list you can see all of. Along *this * conversation's* tabs: stepping into another one's would be a key that changes what you are * working on. */ step(delta: number, view?: ViewId): Promise; /** * Move a tab along the bar, clamped at the ends. This is a drag; a drag that teleports is a bug. * * `to` counts the tabs on the bar, which is the number printed on the strip — the tabs of other * conversations sit between these and are not places a drag can land. */ move(tab: TabId, to: number): Promise; /** Name a tab, or pass `null` to give it back its derived name. */ rename(tab: TabId, title: string | null): Promise; } export interface WindowApi { /** * `gravity` is which end short content settles against: `"start"` (the default) pins it to the * top, `"end"` to the bottom, which is what makes a transcript read as a conversation rather * than as a document that happens to be in a window. * * `wrap` makes long lines fold rather than clip. Docks clip by default — a side panel that * wrapped a long path would reflow every row below it — but a text field is prose and wants * this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its * floor. * * `pane` docks the window inside one pane of the main region rather than against the screen. * The same four edges either way — a composer at the foot of a pane is this call with a `pane`, * and a status line along the foot of the terminal is this call without one. Omit it for * anything that belongs to the whole terminal: a sidebar docked into a pane would be a sidebar * that disappears when you close that split. */ open( buf: BufferId, dock: Dock, opts?: { size?: number; gravity?: Gravity; wrap?: boolean; pane?: PaneId }, ): Promise; close(win: WindowId): Promise; /** * Change how wide (or tall) a docked window is, without closing it. * * Reopening is not the same thing: the window id changes and whatever had the keyboard loses it, * so a panel resized from inside itself would throw the cursor back to the composer on every * press. `null` gives the dock its default extent back. Floats are configured with * {@link FloatApi.configure}, and are refused here. */ resize(win: WindowId, size: number | null): Promise; setBuf(win: WindowId, buf: BufferId): Promise; /** `col` is a UTF-8 byte offset, not a character or display column. */ cursor(win: WindowId): Promise<{ row: number; col: number }>; setCursor(win: WindowId, row: number, col: number): Promise; /** * Put a buffer row at the top of a window, or hand the scroll position back. * * `null` is *unscrolled*, which is where a window starts and is not the same place as row `0`: * a window that follows its content — the transcript — shows its last screenful unscrolled and * its first row at `0`. Anything else shows the same thing either way. */ scrollTo(win: WindowId, topLine: number | null): Promise; /** * Move a window's scroll by a line, a half screen, a screen, or all the way to an end. * * What {@link scrollTo} cannot express, because the arithmetic is not yours to do: "half a screen" * is counted in the buffer rows the frontend actually drew — which on a wrapping window is fewer * than its height — and the floor is the last screenful rather than the last line, so the bottom * is a full panel and not one row above an empty one. Both numbers are a frame old by the time * {@link viewport} hands them to you; this reads them where they live. * * The keys bound on `neosh.scroll` run exactly this. Call it directly for a scroll a key did not * ask for — following output, or a button on a panel of your own. */ scroll(win: WindowId, amount: ScrollAmount): Promise; /** * How big this window actually is, in cells. * * `null` until the frontend has drawn it once. This is the only way to learn real geometry: * everything about display width is resolved by the frontend, so a plugin sizing a meter or * deciding what to drop at 60 columns asks rather than computing an answer it cannot compute * correctly. */ viewport(win: WindowId): Promise; /** * Every window that is open, and what is in it. * * How you find somebody else's panel. A window id belongs to whoever opened it and changes every * time the panel is reopened, so this plus a buffer `kind` is the only way to say "the sidebar, * whichever window that is right now" — and therefore the only way to act on one you did not * open. */ list(): Promise; /** The open windows showing a buffer of this kind. Sugar over `list()`, which is the common case. */ ofKind(kind: string): Promise; /** * Remap group names for one window, or for every window of a buffer kind — Neovim's * `winhighlight`. `{ Normal: "Acme.Panel", "Sidebar.Selected": "Acme.Sel" }` on * `{ kind: "neosh.sidebar" }` recolours the sidebar without redefining the groups anything else * draws with. A window's map sits over its kind's. An empty map clears; the remap is yours and * goes when your plugin does. */ setHighlights( target: { win: WindowId } | { kind: string }, map: Record, ): Promise; } /** * Moving and editing text where a window's cursor is. * * Verbs rather than positions, because grapheme and word boundaries are genuinely hard and nobody * should have to get them right twice. A text field in your plugin behaves the same way the * composer does because they are asking the same question, not because they each reimplemented it. */ export interface EditApi { /** * Move the cursor. `select` extends a selection from wherever it was anchored — anchoring first * if nothing was — which is shift-and-arrow. Without it the selection is dropped. */ move(win: WindowId, motion: CursorMotion, opts?: { select?: boolean }): Promise; /** Edit at the cursor. Typing over a selection replaces it, as everywhere else. */ apply(win: WindowId, edit: TextEdit): Promise; /** Anchor a selection where the cursor is, or drop the one there is. */ select(win: WindowId, on: boolean): Promise; /** * What the two ends of the selection *mean*. * * `"exclusive"` is a text field's: the cursor sits between characters and the one it is on is * not selected. `"inclusive"` is a normal mode's — the cursor is *on* a character and that * character is in — and `"line"` takes whole rows in whichever direction the selection runs. * Dropping a selection puts this back to `"exclusive"`. */ selectShape(win: WindowId, shape: SelectShape): Promise; /** * What the caret looks like here: a bar between two characters, or a block on one. * * The one thing on screen that says whether keys are being typed or obeyed, before any of them * is pressed. */ cursorShape(win: WindowId, shape: CursorShape): Promise; /** What is selected. `""` when nothing is. */ selection(win: WindowId): Promise; /** * Put text on the system clipboard. * * A capability rather than something you could do yourself: the runtime has no terminal, and the * frontend is the only thing holding the stream this has to travel down. Over SSH it reaches the * terminal you are sitting at, which a clipboard library on the remote host would not. */ copy(text: string): Promise; } export interface FloatApi { open(buf: BufferId, opts?: FloatOptions): Promise; configure(win: WindowId, opts?: FloatOptions): Promise; close(win: WindowId): Promise; } export interface NamespaceApi { create(name: string): Promise; /** `col` is a UTF-8 byte offset. */ mark(ns: NamespaceId, buf: BufferId, row: number, col: number, opts?: MarkOptions): Promise; getMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise; allMarks(ns: NamespaceId, buf: BufferId): Promise; delMark(ns: NamespaceId, buf: BufferId, id: ExtmarkId): Promise; clear(ns: NamespaceId, buf: BufferId, start?: number, end?: number): Promise; } export interface HighlightApi { /** * Declare a semantic group. Prefer `link` so an unknown theme still looks right. * * Yours from then on: a theme switch leaves it alone, and unloading your plugin takes it back to * the theme's definition or away. `default: true` is Neovim's `:hi default` — define only if * nobody has — which is what to use for the groups your plugin introduces, so a user's `init.ts` * wins whichever of you loaded first. */ define( name: string, def: { link: string } | HighlightSpec, opts?: { default?: boolean }, ): Promise; /** * What a group is, and what it resolves to after following links. Both `null` for a name nobody * defined. The way to compute "a little dimmer than `Normal`" rather than guess at it. */ get(name: string): Promise<{ def: HighlightDef | null; resolved: HighlightSpec | null }>; /** Every group, with which plugin owns it (`owner` absent for the theme's own). */ list(): Promise; /** Undo your definition of a group. Rejects for a group another plugin owns. */ reset(name: string): Promise; /** * Groups changed — defined, reset, or all of them on a theme switch. `names` says which. A * panel that cached a colour reads it again here; nothing else needs to, because the frontend * redraws on its own. */ onChange(cb: (e: { names: string[] }) => void): Disposable; } export interface RawCellApi { claim(win: WindowId, rect: Rect): Promise; put(surface: SurfaceId, cells: SurfaceCell[]): Promise; release(surface: SurfaceId): Promise; } /** What a command handler is given and what it may give back. */ export type CommandHandler = (args: string[], key?: KeyContext) => unknown | Promise; export interface CommandApi { /** * Register a command by name. Keys bind to the name; `cmd.exec` runs it; `cmd.call` runs it and * returns what the handler returned, so a command is also how one plugin asks another a * question — `sidebar.cursor`, `git.status.of` — without importing it. */ register(name: string, fn: CommandHandler, opts?: { desc?: string }): Promise; /** Run a command and do not wait for it. A key press, from code. */ exec(name: string, args?: string[]): Promise; /** * Run a command and wait for its answer. * * Whatever the handler returned, as JSON — `null` for a handler that returned nothing. Rejects * with the handler's error if it threw, with `not found` if nothing registered the name, and * after a long timeout if the owner never answered. Routed through the host, so it works for * the host's own commands (which answer `null`) and does not care which plugin owns the name. * * For a typed, zero-round-trip call into a plugin you depend on, `import { api } from * "plugin:"` instead — see the `requires` manifest field. */ call(name: string, args?: string[]): Promise; list(): Promise; } export interface KeymapApi { /** * Bind a key to a *command name*, never to a callback. * * That indirection is what makes every binding listable and remappable by the user, and it lets * the host resolve routing without calling into a plugin. * * Scope resolves window → buffer → buffer kind → global, first match winning. `{ kind: "buf_kind", * name: "neosh.sidebar" }` is the one to reach for when the thing you are binding into is * somebody else's panel: a window id is private to whoever opened it and dies with the window, * whereas a kind is a name the panel publishes and every window of that kind — including ones * opened tomorrow — is covered by one call. */ set(mode: Mode, lhs: string, command: string, opts?: { scope?: KeymapScope; desc?: string }): Promise; del(mode: Mode, lhs: string, scope?: KeymapScope): Promise; list(mode?: Mode): Promise; /** * While `win` is focused, receive every key the keymaps did not claim. * * For widgets that need raw input: a filter box, a text field, a modal list. Bindings still win, * so `` keeps quitting while your picker is open — you get what nothing else wanted. The * command is invoked with a `KeyContext`, so one handler can switch on the key. * * Dispose to release. A capture is also dropped when the window closes or your plugin unloads, * so a crash cannot leave the keyboard pointing at nothing. */ capture(win: WindowId, command: string): Promise; } export interface FocusApi { push(win: WindowId): Promise; pop(): Promise; current(): Promise; /** * The keyboard moved. `win` is `null` when nothing has it — the composer. The same fact also * arrives as `neosh.win.enter` / `neosh.win.leave` on the event bus, with the buffer's kind, * which is the form to use when you only care about one panel. */ onChange(cb: (e: { win: WindowId | null }) => void): Disposable; } export interface AgentApi { /** * Send a message. Anything on the composer's attachment row goes with it. * * `images` are extra paths to attach on the way through, for a plugin that has *produced* a * picture rather than one somebody pasted — a rendered chart, a screenshot it took. The * bytes are copied into the workspace, so a temporary file may be handed over and forgotten. */ send(text: string, opts?: { images?: string[] }): Promise; cancel(): Promise; /** * Do something to a conversation by id, rather than to whichever one is on screen. * * The same vocabulary `swarm.command` carries to another machine — steer, interrupt, re-model, * rename, archive, start — pointed at a conversation here. That symmetry is the point: an * orchestrator that fans work out over several conversations and joins the results is one * program whether the conversations are on this laptop or spread over the swarm, and until this * existed it was only writable for the ones that were somewhere else. * * Everything that *watches* a conversation already names one — `onToken`, `onTurnEnd`, * `sessions.messages` — so this is the half that was missing. Without it, driving a second * conversation meant `sessions.switch` first, which moves the screen out from under whoever is * reading it. * * Omit `session` for the conversation on screen. Answers with the conversation the command was * about, which is how `new_session` says what it made. * * ```ts * // Ask three conversations the same thing without touching the screen. * for (const s of await neosh.session.list()) { * await neosh.agent.command({ command: "send", text: "status?" }, s.id); * } * ``` */ command(command: AgentCommand, session?: string): Promise; selection(): Promise; /** Hot-swap the model. Takes effect on the next turn. */ setSelection(selection: ModelSelection): Promise; /** * Every reachable model, each paired with the instance that serves it. * * The pairing matters: a model id is unique per instance, not globally, so a picker that dropped * it would have to guess the owner — and guessing wrong sends the conversation to a different * endpoint with no visible error. * * Answers from a session cache. Discovery is a network round trip per configured provider, so * pass `refresh` only when you have reason to believe a lineup changed. */ listModels(instance?: string, opts?: { refresh?: boolean }): Promise; listInstances(): Promise; /** * What the driver behind this conversation accepts as a slash command. * * Reported by the driver at its handshake, not configured. Which commands exist depends on the * install — `claude` counts project `.claude/commands/`, plugin commands and MCP prompts among * its own — so any list written down in a plugin would be wrong on the first machine that had * one of its own. Empty until the conversation has run a turn: there has been nothing to ask. */ driverCommands(): Promise; /** * Replace what is in the composer, caret at the end. * * For completion: a `/` menu, an `@file` menu, a path menu. Pair with the `composerChanged` * event, which is the other half — one says what has been typed, this puts the answer back. */ setDraft(text: string): Promise; /** * Attach an image to whatever is about to be sent. * * With a path, that file. Without one, whatever image is on the system clipboard — which is * the only way a picture can reach a terminal at all: bracketed paste is a text protocol, and a * screenshot pasted into one arrives as nothing. That is why `^V` is a key rather than a paste. * * The bytes are copied into the workspace's own directory, sniffed for what they actually are * rather than what they are called, and shrunk if they are enormous. Rejects when there is no * image to be had, with a reason worth showing. */ attach(path?: string): Promise; /** What is attached to the composer right now, oldest first. */ attachments(): Promise; /** * Take something off the attachment row: the one at `index`, or the newest. * * Answers with what came off, or nothing if there was nothing there — the row may have * gone out with a send between asking and answering, and that is not an error. */ detach(index?: number): Promise; /** Take the whole attachment row off. Answers with what was on it. */ detachAll(): Promise; /** * Where each configured provider's key comes from — and never what it is. * * There is deliberately no call that returns a secret. A plugin can find out that `anthropic` is * authenticated from the keychain, and can ask the host to collect a new key; it cannot read one, * so it cannot leak one. */ credentials(): Promise; /** * Ask the host to collect an API key for `instance` from the keyboard. * * The host runs this prompt itself: a plugin one would have to put the key in a buffer, and a * buffer is drawn — the value would cross the frontend boundary and land in whatever it logs. * What comes back is whether a key was stored, never the key. * * Rejects when the instance signs in on its own (a CLI login) or needs no key at all, and when it * already has one unless you pass `replace`. */ setCredential(instance: string, opts?: { replace?: boolean }): Promise; /** Drop a stored key from memory and from the keychain. The environment is not ours to clear. */ forgetCredential(instance: string): Promise; /** * The model this conversation will use changed, whoever changed it. * * Not the same as `opt.onChange` for `agent.model`: that option is a preference, and the * selection also moves when a conversation is restored, when a stored model turns out not to * authenticate, and when a provider registers late and the model somebody asked for finally * becomes reachable. Anything that names the model — a footer, a context meter measuring against * its window — wants this one. */ onSelectionChange(cb: (e: { selection: ModelSelection }) => void): Disposable; /** * The composer's text changed — a keystroke, a paste, a conversation switch, a send. * * The other half of {@link AgentApi.setDraft}. Completion of any kind is these two: watch what * has been typed, offer something, put the answer back. */ onComposerChange(cb: (e: { text: string }) => void): Disposable; /** * What a driver's own loop said about itself — a sub-agent, a plan, a compaction, how full its * context is. * * The only signal that moves *during* a turn. Everything else about usage arrives when the turn * ends, which for an agent driver can be twenty minutes after the number changed. */ onActivity(cb: (e: { session: SessionId; turn: string; activity: Activity }) => void): Disposable; /** * A turn has begun. * * Every turn event says which conversation it belongs to. A workspace runs several at once, so * anything that draws a turn has to check: by the time one ends, the conversation it ran in may * not be the one on screen. `neosh.session.list()` flags the active one. */ onTurnStart(cb: (e: { session: string; turn: string }) => void): Disposable; /** One streamed chunk of assistant text. Chunks are provider-sized, not characters. */ onToken(cb: (e: { session: string; turn: string; text: string }) => void): Disposable; onThinking(cb: (e: { session: string; turn: string; text: string }) => void): Disposable; onTurnEnd( cb: (e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void, ): Disposable; /** * A tool is about to run. * * Distinct from the `tool_pre` hook: that one is asked *whether* the call may proceed and can * veto it. This is told that it is happening, cannot influence it, and is therefore what a * transcript wants. */ onToolStart(cb: (e: { session: string; turn: string; call: ToolCall }) => void): Disposable; onToolEnd( cb: (e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void, ): Disposable; } export interface ToolApi { /** Lands in the same namespace and shape as a built-in or MCP tool. */ register( def: { name: string; description: string; inputSchema: Record }, handler: (input: unknown) => ToolResult | Promise, ): Promise; list(): Promise; } export interface HookApi { /** * Register a hook. * * `blocking: false` (the default) is a pure observer whose return value is ignored — that is what * stops an audit plugin from wedging the agent loop. A blocking hook is awaited and may veto; * **a blocking hook that does not answer in time is treated as a veto**, so a policy plugin fails * closed. */ register( hook: HookName, fn: (payload: HookPayload) => HookOutcome | Promise, opts?: { blocking?: boolean; timeoutMs?: number }, ): Promise; } export interface ProviderApi { /** * Register a model provider implemented in this plugin. * * Supporting another vendor is a plugin, not a core change. Because a stream cannot cross the * RPC boundary as a return value, `handler` receives an `emit` callback and pushes events until * it emits `message_stop`. */ register( driver: string, instances: InstanceConfig[], handler: (req: TurnRequest, emit: (e: ProviderEvent) => void, signal: { cancelled: boolean }) => void | Promise, opts?: { /** * This driver runs its own agent loop — it has its own tools, and calls them itself. * * neosh then sends it no tool list, does not execute the calls in its stream, and records the * conversation in the shape that actually happened. Leaving it off for such a driver makes * the host run every tool call a second time. */ agentLoop?: boolean; }, ): Promise; } /** * Version control. * * The plugin runtime has no process access, so `git` is a host capability rather than something a * plugin shells out to. One implementation means a sidebar, a branch picker and a commit UI agree * about what "dirty" means instead of each parsing porcelain slightly differently. * * Reads are free. Writes go through the permission layer as `exec` of `git `, so * `permissions.allow_commands = ["git"]` covers them and a policy hook watching exec sees them. * * Every call rejects with `not_found` when neosh was started outside a repository — check once with * `status()` rather than guarding each call. */ export interface GitApi { /** The working tree's state. `cwd` is any checkout; omitted, the one this conversation is in. */ status(opts?: { cwd?: string }): Promise; /** Local branches, most recently committed first. */ branches(opts?: { includeRemote?: boolean; cwd?: string }): Promise; /** * Every checkout of the repository. * * `cwd` picks which repository to ask about; without it the answer is the one this conversation * is in, which is what a status bar or a branch picker means. A panel means the other thing — * it lists several projects at once, and the row under the cursor is not always the conversation * you are in. */ worktrees(opts?: { cwd?: string }): Promise; log(limit?: number): Promise; /** The patch. Pass `stat` for `--stat`, which is what a prompt wants. */ diff(target?: DiffTarget, opts?: { stat?: boolean }): Promise; /** What this branch would merge into: `origin/HEAD`, else `main`/`master`. */ defaultBranch(): Promise; createBranch(name: string, opts?: { from?: string }): Promise; /** * Move a branch to another name — `git branch -m`. * * One ref write. The working tree is untouched, so this is safe on a branch that is checked out * and safe while an agent is editing files against it — which is the case it exists for: naming * a worktree's branch from the first message, once there is a message to name it from. * * `cwd` is the checkout the branch belongs to, and you almost always want it: the worktree being * renamed is very often not the one the active conversation is standing in. * * Fails if `next` is taken — a generated name does not get to overwrite somebody's branch. Ask * `branches()` and pick a free one. */ renameBranch(name: string, next: string, opts?: { cwd?: string }): Promise; checkout(rev: string): Promise; /** Empty `paths` stages everything, like `git add .` from the repository root. */ stage(paths?: string[]): Promise; unstage(paths?: string[]): Promise; commit(message: string): Promise; /** * `git fetch --prune`, answering with where the tree stands *after* it. * * **The one call that makes `ahead` and `behind` mean anything.** `status()` reads them off * `git status --branch`, which compares HEAD with the remote-tracking ref sitting on this disk — * so a panel drawing `↓0` from it is reporting the state of the world as of whenever this * checkout last spoke to a remote. Call this first and the number is news; do not, and it is * archaeology. * * It answers with the fresh {@link RepoStatus} rather than nothing, so "fetch and see where I am" * is one round trip. Prunes, because a picker offering six branches that were deleted when their * pull requests merged is worse than one that is a fetch behind. * * A write, and the reason is the network rather than the working tree, which does not move: * this contacts a remote and writes refs. It fails rather than hangs when there are no * credentials — stdin is closed and every askpass unset — which is what makes it safe to put on * a timer. Expect it to reject routinely: no remote, no network, a key the host will not take. */ fetch(opts?: { cwd?: string }): Promise; /** * Every pull request the forge knows about for this repository, open and closed. * * **One call answers for every checkout.** They come back keyed by * {@link PullRequest.branch}, so a repository with six worktrees is one request and six lookups * — which is the difference between a panel that costs a round trip every few minutes and one * that costs six. * * Answered by `gh`, the CLI the user has already signed into, so tokens, enterprise hosts, SSO * and rate limits are already handled and the answer is the one they get in their own terminal. * * **Rejects rather than answering empty** when it cannot know — no `gh`, not signed in, no * remote on a forge. An empty list is the claim "this repository has no pull requests", and * three of the four ways this fails would be making it falsely. Catch it, say it once, and do * not say it again every tick. */ pulls(opts?: { cwd?: string }): Promise; /** * `git pull`, answering with git's own summary — "Already up to date.", the fast-forward range — * because those are different answers and a caller showing neither is a caller nobody trusts. * `cwd` picks the repository, as everywhere; absent means the conversation's own. * * `rebase` replays this branch's commits on top of what arrived instead of merging them. It is * the answer to a diverged branch and it rewrites local commits, so ask before you set it — the * caller this exists for is a panel that has just said "diverged" and offered the choice. */ pull(opts?: { cwd?: string; rebase?: boolean }): Promise; addWorktree( path: string, branch: string, opts?: { create?: boolean; cwd?: string }, ): Promise; /** * `cwd` names the repository the worktree belongs to. `git worktree remove` must run from a * checkout other than the one being removed, and the active conversation may be standing in * exactly that one. */ removeWorktree(path: string, opts?: { force?: boolean; cwd?: string }): Promise; /** * Move a worktree to `dest` — `git worktree move`, and everything that has to follow it. * * A worktree's path is an identity here, not a coordinate: it is the key of the project facts * every list draws, of the conversations living in it, of the project vars holding a pin and a * fold state, and of the working directory each vendor CLI was started in. This call moves all * of them, which is why it is a call rather than a `git worktree move` you could have run * yourself — the git part is the part that was never hard. * * `dest` is the full path it lands at. Its parent is created; the leaf must not exist. * * Refused while a turn is running anywhere in the tree, because a CLI holds its working * directory from the moment it is spawned and would otherwise write the file it is editing into * the place the tree used to be. Interrupt it and ask again. * * `cwd` names the repository, as everywhere. */ moveWorktree(path: string, dest: string, opts?: { cwd?: string }): Promise; /** * Clone `url` into `path`, resolving to the path once it is there. * * The one call here with no `cwd`: everything else asks a repository a question, and this one * arrives before there is a repository to ask. `path` is absolute and its parent need not * exist — cloning into a location you have just invented is the ordinary case. * * **It reports progress while it runs**, on the bus as {@link CLONE_EVENT}, so a caller that * wants to draw a clone rather than block on one subscribes before awaiting this. Keyed by * `path`, because a workspace may be cloning two things at once. * * Needs `vcs_write` in the manifest, like every other call here that changes a disk. */ clone(url: string, path: string): Promise; } /** * What {@link GitApi.clone} says about itself while it runs, on {@link EventsApi.on}. * * `phase` is git's own word for what it is doing — `Receiving objects`, `Resolving deltas` — and * `percent` is absent for the phases that have no total, which draw as a spinner rather than as a * bar. `done` arrives exactly once per clone, on success and on failure alike, because a panel * drawing itself from these has no other way to learn it may stop. */ export const CLONE_EVENT = "neosh.git.clone"; /** * Where an update says how it is getting on, while {@link UpdateApi.apply} runs. * * On the bus rather than back to the caller, because the caller is not always the thing drawing * — `neosh agent run update` asks and the sidebar's plugin shows it. The payload is * {@link UpdateProgress}; the last one has `done` set, whatever happened. */ export const UPDATE_EVENT = "neosh.update.progress"; /** One {@link CLONE_EVENT} payload. */ export interface CloneProgress { /** Which clone this is about. The key, since two may be running. */ path: string; url: string; phase: string; percent?: number | null; done?: boolean; /** Present, with git's own last word in it, only when the clone failed. */ error?: string; } /** * One-shot generation: a prompt through a model, outside the conversation. * * Branch names, commit messages, thread titles and PR descriptions are all this call. It is * deliberately *not* `agent.send` — nothing here enters session history, so asking for a commit * message does not change what the agent believes it was asked to do. * * The model is `gen.model` when set, else the conversation's own. Point that option at something * cheap; naming a branch does not need a frontier model. */ export interface GenApi { complete(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise; /** * Same, but parse the answer as JSON. * * The host tolerates what models actually return — code fences, a "Sure!" preamble — so callers * do not each reimplement that. Rejects if there is no JSON in the response at all. */ json(prompt: string, opts?: { system?: string; selection?: ModelSelection }): Promise; /** * One value, asked for as JSON and accepted however it comes back. * * The shape almost every generating plugin actually wants: a branch name, a thread title, a PR * subject — one string, asked for as `{"branch": …}` because that is how you pin a model down, * and answered as a bare `fix/composer-paste` often enough to matter. Both are the same answer, * and {@link GenApi.json} rejects the second one — which is a correct name thrown away, with * nothing on screen to say so. * * So the key is passed down and a bare reply is read as its value. Everything * {@link GenApi.json} tolerates is tolerated first and unchanged; this is only what happens when * there is no JSON at all. Rejects on an empty answer, or on prose it will not guess at. * * For one value only. A commit message is a subject and a body, and there is no answering the * question of which one a lone paragraph is — that stays {@link GenApi.json}. * * And only for a value you would know was wrong on sight. A branch name is that, and a wrong one * is one rename away; a thread title is any short line, and so is a refusal or a driver's own * error message — where nothing distinguishes an answer from a remark, the envelope is the * evidence, and {@link GenApi.json} is the call. */ field( prompt: string, key: string, opts?: { system?: string; selection?: ModelSelection }, ): Promise; } /** * Conversations. * * A workspace is not one conversation: a branch you are on, a review you are half-way through, a * question from yesterday. These are the verbs a thread list needs; what it looks like is yours. * * Conversations are saved to the state directory as you go and restored at startup, so switching * away from one is not a way to lose it. */ /** * The terminals looking at this workspace. * * A workspace can have several and they are not copies of each other: each has its own * conversation on screen, its own scroll position, its own composer and its own panels. What they * share is the work — the conversations themselves, the turns running in them, everything a plugin * registered. * * A plugin that owns a **dock** has to open one panel per view, and `onOpen` is when. A plugin that * only opens floats in answer to a key needs none of this: the host puts a float in the terminal * whose key press opened it. */ export interface ViewApi { /** Every terminal, and what each is looking at. */ list(): Promise; /** The one being served — the terminal whose key press is running. */ current(): Promise; /** * The whole `neosh` namespace, bound to one terminal. * * Every call on it is the call it always was, except that a window opened through it lands * there. The same object a command handler is given as its third argument. */ at(view: ViewId): Neosh; /** * A terminal arrived. Open your panel in it. * * Fired for every view that already exists when the plugin loads, too, so a plugin does not have * to decide whether it was here first. */ onOpen(cb: (view: ViewId) => void): Disposable; /** A terminal went away. Its windows are already closed; let go of what you were keeping. */ onClose(cb: (view: ViewId) => void): Disposable; } export interface SessionApi { /** * Most recently active first, with exactly one flagged `is_active`. * * Archived conversations are left out unless you ask for them. That is what archiving is for, and * a list that included them by default would make every caller responsible for remembering. */ list(opts?: { includeArchived?: boolean }): Promise; current(): Promise; /** * Start one. It inherits the model and system prompt you are using — starting a conversation * should not silently change what you are talking to. * * `cwd` opens it against another checkout, which is how a second project becomes visible. */ create(opts?: { cwd?: string; title?: string; activate?: boolean }): Promise; /** * Look at another conversation. * * Never refused, including while a turn is running. A turn belongs to its conversation and keeps * streaming into it; what you see is rebuilt from whichever one you switched to, and switching * back puts you in the middle of the answer where you left it. */ switch(session: SessionId): Promise; /** * Close one. Closing the active conversation moves to the most recently used other; closing the * last one is an error, because there is always somewhere for the next thing you type. * * A turn running in it is cancelled: there is about to be nowhere to put its answer. * * A conversation this workspace never loaded — one past the restore cap, which {@link stored} * is how you find — is deleted from disk just the same. One verb, whether the store is holding it * or only the directory is. */ close(session: SessionId): Promise; /** Pass `null` to clear a title and go back to the first-message label. */ rename(session: SessionId, title: string | null): Promise; /** * Put a conversation away, or bring it back. * * Not `close`: nothing is deleted, every message survives, and `list({ includeArchived: true })` * still finds it. Archiving the active conversation moves you to the most recently used other * one — or to a fresh empty one if there is no other. */ archive(session: SessionId, archived?: boolean): Promise; /** * Every conversation *on disk*, loaded or not — newest first. * * {@link list} answers about the workspace's store, and the store is a window rather than the * whole directory: a workspace restores the most recent few hundred conversations and leaves the * rest as files. Those files are in no list, which is fine until something has to say what has * accumulated or take it away — so an archive that only ever asked `list` would report a number * that was not the number, and empty itself down to a directory that was still full. * * The rows are ordinary {@link SessionInfo}s, so one renderer draws both. Which of them this * workspace is actually holding is the difference between the two calls, and a caller that cares * asks both and compares ids. * * It reads and parses every file, so it is answered off the host loop and is not something to put * on a redraw. */ stored(): Promise; /** The conversation itself, for a transcript view that renders rather than replays. */ messages(session?: SessionId): Promise; /** * Fires whenever a terminal is looking at a different conversation — switched, created, closed. * * `view` is which terminal moved. A workspace can have several and each is somewhere, so * "the active conversation" is a question with as many answers as there are screens. */ onChange(cb: (e: { session: SessionId; view: ViewId }) => void): Disposable; } /** * The status line — the composer footer. * * The host owns the strip: one line, always visible, never scrolled. Plugins own what is in it. * That split is what lets the model switcher and the git plugin each put something there without * either knowing the other exists. * * Setting the same key again replaces that segment, so updating a meter every tick does not need a * clear first and cannot leave two. Segments are namespaced per plugin, so two plugins choosing * `"model"` cannot collide, and unloading a plugin takes its segments with it. */ /** * The shortcut row under the composer. * * Whoever owns a feature owns its hint, which is the only arrangement that stays true: the row is * built from what is actually registered right now, so a plugin that is switched off takes its * shortcut with it rather than leaving a key advertised that no longer does anything. * * Write the key the way the user would press it — `^P`, `⇧⏎`, `^Z` — not the way a keymap spells * it. Hints are dropped from the end when the terminal is too narrow, so put the one you would * most want seen at the lowest priority. */ export interface HintApi { set(key: string, hint: { keys: string; label: string; priority?: number }): Promise; clear(key: string): Promise; } export interface PermissionApi { mode(): Promise; setMode(mode: PermissionMode): Promise; } export interface StatusApi { /** * `keys` is drawn immediately after `text`, dimmed — the key that changes this thing, beside the * thing it changes. Write it the way the user would press it (`^P`, `^Z`), not the way a keymap * spells it. * * `short` is the same thing said in less room, and the strip asks for it before it drops your * segment. Give one to anything wide: without it a segment costs its full width or nothing, so * the widest thing in the strip is the first thing to vanish on a narrow terminal — which is * usually the thing worth the most. It is not a truncation and the host will not invent one; it * is the fact with a part left out, and only you know which part that is. * * `priority` is where the segment sits *and* what the strip gives up first, in reverse. */ set( key: string, segment: { text: string; /** The same fact in fewer columns, used before this segment is dropped for want of room. */ short?: string; keys?: string; hl?: string; align?: StatusAlign; priority?: number; }, ): Promise; clear(key: string): Promise; } export interface OptionApi { /** * Declare an option this plugin owns. * * neosh's own settings are declared through this same call at startup, so there is nothing the * built-in options can do that yours cannot — including being set from `config.toml` and shown * by a settings UI that has never heard of your plugin. * * Names are dot-separated lowercase. Namespace yours under your plugin id. */ declare(spec: OptionSpec): Promise; /** Typed read. Rejects if the option was never declared. */ get(name: string): Promise; /** Full entry, including type, default and owner — or `null` if undeclared. */ entry(name: string): Promise; /** * Set a declared option. Rejects on an unknown name or a value that does not match the declared * type, rather than quietly doing nothing. */ set(name: string, value: OptionValue): Promise; /** Restore the declared default. */ reset(name: string): Promise; all(): Promise; /** Fires for every option, not just your own: a setting is shared state. */ onChange(cb: (e: { name: string; value: OptionValue }) => void): Disposable; } /** * What your plugin remembers between runs. * * Small facts a panel needs so it is still arranged the way you left it: which projects are pinned, * what order you dragged them into, which sections are folded. Deliberately *not* options — an * option is configuration the user writes, and a plugin that rewrites someone's config file because * they pressed a key is one they stop trusting with the file. * * Keyed by your plugin id, which the host knows and you cannot forge, so no other plugin can read * or clobber what you store. It is plain JSON on disk: **nothing secret belongs here.** */ export interface StateApi { /** * `null` when nothing was stored — indistinguishable from having stored `null`, which no caller * has ever needed to tell apart. */ get(key: string): Promise; set(key: string, value: unknown): Promise; remove(key: string): Promise; } /** * What *everybody* remembers about a project or a conversation. * * The counterpart to `state`, and the difference is who may look. State is keyed by your plugin and * private, which is right for your fold set and wrong for "this project is a favourite" — with * state, a sidebar of somebody's own starts with no favourites and pinning one in ours is invisible * to it. A var is scoped to the thing it describes, and anyone may read or write it. * * Namespace your keys (`sidebar.favorite`, `acme.colour`) for the reason options are namespaced: * nothing stops two plugins choosing `colour`, and a prefix is what makes them not want to. * * Persisted, shared, and plain JSON on disk: **nothing secret belongs here.** A conversation's vars * are deleted with it; a project's outlive every conversation in it, because a project is a * directory and the directory is still there. */ export interface VarApi { get(scope: VarScope, key: string): Promise; set(scope: VarScope, key: string, value: unknown): Promise; remove(scope: VarScope, key: string): Promise; /** Everything on one scope, in one round trip. What a panel reads per project rather than per key. */ all(scope: VarScope): Promise>; /** * A var changed, whoever changed it — including you. The signal to redraw on. * * `value` is `undefined` when it was removed. */ onChange( cb: (e: { scope: VarScope; key: string; value: unknown }) => void, ): Disposable; } /** * The directories somebody works in, and the one thing that can happen to one. * * A project *is* its path everywhere else in this API — `projectScope` keys vars by it, a panel's * list is a list of them, `SessionInfo.cwd` names one. Which is exactly why a path that changes * needs saying out loud rather than inferring: from the outside, a worktree that moved and a * project that was deleted while another was added are the same two facts in the same order. */ export interface ProjectApi { /** * A project's directory is now somewhere else — a worktree that was relocated. * * The host has already moved everything it owns by the time this arrives: the conversations, the * project vars, the names and branches every list draws. What is left is whatever *you* keyed by * the old path — a list of directories, a cache, a decoration target — and the point of getting * both ends in one event is that you can re-key in place instead of dropping a row and gaining a * stranger. */ onMove(cb: (e: { from: string; to: string }) => void): Disposable; } /** Sugar for the two scopes anything with a panel spends its time in. */ export function projectScope(cwd: string): VarScope { return { scope: "project", cwd }; } export function sessionScope(session: SessionId): VarScope { return { scope: "session", session }; } /** * How your plugin puts something in somebody else's panel. * * A *point* is a name a plugin agrees to read — `sidebar.section`, `project.action`, `palette.entry` * — and a contribution is a JSON item on it, conventionally carrying the name of a command to run. * The indirection is the whole trick: the sidebar renders rows it did not write and invokes commands * it has never heard of, and neither side imports the other. * * Data rather than a callback on purpose. A contribution can be listed by the palette, described in * `^Z` and disabled by the user, none of which is possible for a function held inside your closure. * * Your contributions are withdrawn when your plugin unloads, so `plugins.disabled` takes your rows * with it and there is no way to leave a row behind pointing at a command that no longer exists. */ export interface ExtensionApi { /** * Put an item on a point, replacing whatever you had there under the same `id`. * * Higher `priority` sorts first; ties break on plugin and id, so the order is stable across * restarts rather than being whatever order plugins happened to activate in. */ contribute( point: string, id: string, item: unknown, opts?: { priority?: number }, ): Promise; remove(point: string, id: string): Promise; /** Everything on a point, in order, whoever contributed it. What a panel calls when it draws. */ list(point: string): Promise>; /** * Somebody added to or withdrew from a point. Redraw. * * Without this a plugin that loads after your panel has drawn contributes rows nobody sees until * the next unrelated refresh — which on a quiet workspace is several seconds of a panel missing * half of itself. */ onChange(cb: (e: { point: string }) => void): Disposable; /** * Every point anybody reads or writes: who declared it (`[provides] points` in their manifest) * and who has something on it. A point with contributors and no readers is almost always a * typo, and neosh says so at startup. */ points(): Promise; /** * Every plugin the workspace knows about, with its manifest and what became of it — `loaded`, * `held` until one of its activation triggers, or `failed` with the reason. The list a plugins * panel is drawn from. */ plugins(): Promise; } /** * Saying that something happened, to whoever cares. * * Plugin-defined and broadcast — Neovim's `User` autocmd. Nothing validates the names; namespace * them like everything else. * * Fire and forget by construction. There is no reply and no way to be blocked, because an emitter * that could be is an emitter every listener is on the critical path of, which is how one slow * plugin wedges a panel. When you need an answer, register a command or read a contribution point. * * The host emits one of its own: **`neosh.ready`**, `from: "neosh"`, once every plugin has loaded. * Your `activate` returning is not that moment — the others are still loading alongside you, so a * command you would call is a name nothing answers to yet, a contribution point somebody else * fills is still empty, and a model a plugin registers is not selectable. Anything that depends on * the *rest* of the workspace goes in a `neosh.ready` listener rather than at the end of * `activate`. It is said again after `^R`, which is the same fact being true a second time. */ /** What the host says about a window on the bus: `neosh.win.enter`, `.leave`, `.open`. */ export interface WindowEvent { win: WindowId; buf: BufferId | null; /** The buffer's kind — the field to filter on. */ kind: string | null; } export interface EventApi { emit(name: string, data?: unknown): Promise; /** * Listen. `from` is the plugin that emitted it, stamped by the host — one of the few things in a * plugin message nobody can forge. * * You hear your own events too. Filtering on `from === ctx.plugin` is how you skip them, and it * is deliberately your choice: a panel that reacts to its own writes uniformly has one code path * instead of two. */ /** * Hear one event by name. `kind` keeps only events whose `data.kind` matches — the way to * listen for `neosh.win.enter` on the sidebar and nothing else. * * The host's own, `from: "neosh"`: `neosh.ready`; `neosh.win.enter` / `neosh.win.leave` / * `neosh.win.open` (a {@link WindowEvent}); `neosh.win.close` (`{ win }`); `neosh.cursor` * (`{ win, row, col }`); `neosh.mode` (`{ mode }`); `neosh.viewport` (`{ win, width, height }`). * Neovim's autocmds, on the same bus a plugin's own events travel. */ on(name: string, cb: (e: { data: unknown; from: string }) => void, opts?: { kind?: string }): Disposable; /** Every event, whatever it is called. For a logger or a debugger, rarely for a feature. */ onAny(cb: (e: { name: string; data: unknown; from: string }) => void): Disposable; } /** * The other computers. * * ASCP — see `docs/ascp/SPEC.md`. Everything here is a *description* of what another machine is * running, or a *request* to it, and never a handle: an agent belongs to the node it was started on * for the whole of its life, because its files, its shell and its credentials are there. * * Empty and harmless when no swarm is configured, which is the default. A plugin that draws remote * agents needs no special case for the single-machine setup — `nodes()` is simply empty. */ /** * What the plan has left, and what the week went on. * * Two questions that look alike and are not. {@link list} is *now* — an opaque fraction of an * allowance the vendor enforces, which is the number that decides whether to start something. * {@link usage} is *history* — tokens and their money-equivalent, read out of the vendor CLIs' own * transcripts, which is the number that explains where the week went. Neither converts into the * other, and a chart that put them on one axis would be inventing an exchange rate. * * Everything here is data. Nothing in this API draws, which is what makes the bundled strip * replaceable by a panel of your own that reads exactly the same calls. */ /** * Finding out there is a newer neosh, and becoming it. * * The half that decides everything is not *is there something newer* but *may I replace this file*. * A binary under a Homebrew prefix belongs to Homebrew, and writing over it leaves `brew` describing * a version that is not on disk. So {@link UpdateStatus.method} is read off the running executable's * path, and only a `standalone` install is ever overwritten — everything else is handed the command * that would do it, for a person to run. */ export interface UpdateApi { /** * What is running, what is published, and what updating would mean on this machine. * * Answers from the last check unless `force`, so a panel row asking on every redraw costs * nothing. `latest` absent is *unknown* rather than up to date — a failed check that reads as * "you are current" is a machine that never updates and never says why, so draw * {@link UpdateStatus.error} when it is there. */ check(force?: boolean): Promise; /** * The same answer, from this machine alone, never touching the network. * * Which is enough for the half that matters most often. {@link UpdateStatus.restart_pending} is a * `stat` of one file — *is the binary on disk the binary this workspace is running* — and it goes * true when somebody runs `brew upgrade neosh` in another terminal, which is how most installs * are actually updated and the moment nothing used to say anything. Cheap enough to poll on a * tick; {@link check} is not, and a panel that used it for this would be a workspace making an * HTTP request every thirty seconds. */ local(): Promise; /** * Update, by whichever route this install takes. * * A `standalone` binary is downloaded, checked against the published sha256 and swapped in with * a rename — which is atomic and is allowed while the old binary is still executing, and is why * the answer carries `restart_required` rather than the new version simply being live. A managed * one is updated by **running its manager** — `brew update && brew upgrade neosh`, `npm install * -g`, `cargo install --force` — and then checked: a manager that exited 0 and left the binary * unchanged is `failed` with the command to run by hand, never `applied`. * * **It reports progress while it runs**, on the bus as {@link UPDATE_EVENT}: a `brew upgrade` is * a fetch and a `cargo install` is a compile, and a call that answers once at the end is a * progress row that spins on nothing for minutes. One at a time, workspace-wide — a second call * while one is running comes back `failed` saying so. */ apply(): Promise; /** * Restart the workspace so a downloaded update takes effect. * * Rejects with `Busy`, naming them, while any turn is in flight in *any* conversation — a * restart ends turns belonging to terminals the caller cannot see. `force` goes ahead anyway, * and is for a caller that has said out loud what would be lost. */ restart(force?: boolean): Promise; } export interface QuotaApi { /** * The latest snapshot for every instance that has one, freshest observation first. * * Answers from what the workspace kept rather than by asking a vendor, so it costs nothing and is * safe to call on every redraw. `observed_at` is how stale each one is — draw it, because a * percentage with no age on it is one people will trust for longer than they should. */ list(): Promise; /** * Ask the vendor now, for one instance or for every instance that can be asked. * * Resolves as soon as the request is *made*. A poll is a network round trip or a process spawn, * so a panel that awaited the answer would be a panel that opens late; the answer arrives at * {@link onChange}, the same way an unprompted one does, so there is one code path rather than * two. */ refresh(instance?: string): Promise; /** * Publish a snapshot for an instance your own driver serves. * * The other half of {@link ProviderApi.register}: a provider written as a plugin knows its * vendor's allowance and nothing in the workspace does. Reported this way it is kept, sampled, * broadcast and drawn exactly like a built-in one. Rejects for an instance your plugin did not * register a driver for — a figure anybody could spoof is not one this should draw as fact. */ report(snapshot: QuotaSnapshot): Promise; /** * Every percentage this workspace has seen in a span, so a gauge can be a line. * * `since` and `until` are unix **seconds**, `until` exclusive. Sampled on change, so the points * are unevenly spaced and the gaps are real: a stretch with no samples is a stretch where the * machine was off, and drawing a straight line across it invents usage that did not happen. */ history(opts: { since: number; until: number; instance?: string }): Promise; /** * Tokens and their money-equivalent over a span, from the vendor CLIs' transcripts. * * Not from this workspace's conversations: a turn you ran in `claude` directly spent the same * allowance, and a history that could not see it would answer a different question. Reads * thousands of files, so this is a call a panel makes when it opens — never one it makes on a * redraw. * * `cost_usd` is what those tokens would cost at API rates. It is not money spent: a subscription * bills separately. Check `fully_priced` before putting a currency symbol in a heading. */ usage(opts: { since: number; until: number; resolution: UsageResolution; /** IANA zone to bucket days in. Defaults to this machine's. */ timeZone?: string; }): Promise; /** * Any account's allowance changed, whatever moved it: a driver reporting mid-turn, a poll * landing, a plugin publishing its own. * * One thing to listen to instead of knowing which vendors exist — which is what lets a strip draw * a provider that shipped after it did. */ onChange(cb: (snapshot: QuotaSnapshot) => void): Disposable; } export interface SwarmApi { /** This machine, or `null` when the swarm is not running. */ self(): Promise; /** Every node known, up or down. A node that has gone keeps its agents, marked `up: false`. */ nodes(): Promise; /** Every agent on every reachable node, flattened, each carrying the node it belongs to. */ agents(): Promise; /** * Which *other* machines have this project. * * The answer to "is this project on more than this computer". Empty when it is only here. Keyed * on {@link ProjectKey}, which is the normalised git remote — the one thing about a checkout that * is the same on every machine that has it. */ hostsOf(project: ProjectKey): Promise; /** * Ask a node to do something with one of its agents. * * Settles when that machine answers — every ASCP command is answered exactly once — and rejects * with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its * {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb * rather than offering one that will bounce. * * Answers with the conversation a `new_session` created, and `null` for everything else — which * is what makes starting something over there a place you can then go: pass it to * {@link subscribe}, or to `swarm.open`, without waiting for the next inventory to notice it. */ command(node: NodeId, session: string, command: AgentCommand): Promise; /** * Which directories on another machine start with `prefix` — `path.complete`, asked over there. * * The same answers in the same shape as the local one: each entry as you would have typed it, * trailing separator and all, so a single field can complete against either machine and the only * thing that changes is which one is asked. Directory names only. * * {@link NodeCapabilities.projects} is what a machine *offers*, and it is the short list of * places it already works in; this is how you reach the directory over there that neither machine * has ever opened. Rejects when the peer is not connected, when it does not accept commands, and * when it is running a neosh too old to have heard of the question — the message says which, and * an empty list is a directory with nothing in it rather than a failure. * * Passing this machine's own node id answers locally, so a picker that walks `nodes()` needs no * special case for the row that is itself. */ browse(node: NodeId, prefix?: string): Promise; /** * Open a **shell** on another machine, in a tab of its own. * * The same terminal `T` opens here, with the pty on the other end of a link: the emulator * runs in this workspace, exactly as it does over ssh, and what crosses the wire is bytes. Every * key goes to the child, `^C` interrupts *it*, and the window prefix is the one way out. * * `cwd` is a path on the **owner** — learnt from a conversation over there or from * {@link NodeCapabilities.projects}, never invented — and `undefined` is that user's home * directory. * * Rejects when the peer is not connected, when it is too old to have heard of the question, and, * the one that is a decision rather than a fact, when it has not been told to open shells for * other machines. That is `accepts_shells` under `[swarm]`, off by default and deliberately not * covered by `accepts_commands`: starting an agent over there is a thing with a permission layer * over it, and a prompt is not. The rejection names the setting. */ shell(node: NodeId, cwd?: string): Promise; /** * Watch a remote conversation: its history now, then everything as it happens, delivered to * {@link onStream}. * * Drop it when you stop looking. A subscription is the difference between a quiet swarm and one * where every machine sends every token to every other one. */ subscribe(node: NodeId, session: string): Promise; unsubscribe(node: NodeId, session: string): Promise; /** * Ask what machine is at an address, without joining it. * * The first half of pairing. A node presents its identity to anything that connects — as an SSH * server presents a host key — so what comes back was *proven*, not claimed, which is what makes * it safe to show somebody and ask. Rejects, with the address in the message, when nothing * answers. */ probe(addr: string): Promise; /** * Authorise a machine and start connecting to it. * * Immediate: no restart. Written to the state directory rather than to `config.toml`, because an * editor that edits your config file because you pressed a key is one you stop trusting with it. */ pair(node: NodeId, opts?: { name?: string; addr?: string }): Promise; /** Withdraw authorisation and stop connecting. Refuses for a machine your config declared. */ unpair(node: NodeId): Promise; /** * Call a machine something else — an SSH `Host` alias, and for the same reasons. * * A hostname is chosen by whoever set the machine up, is often `Mac` or `ubuntu` on every box * somebody owns, and is not what its owner calls it. This wins over the announced name * everywhere a `SwarmNode` is read, so every panel agrees; `null` gives the announced name back. * * Refuses for a machine your `config.toml` declared, naming the `name` field to change instead — * an alias stored beside a config peer would be reverted by the next reload. */ rename(node: NodeId, name: string | null): Promise; /** * Dial a down peer again now, rather than waiting out its retry delay. * * Also what lifts a {@link disconnect}: for a peer that dials *this* machine, being willing to * answer again is the whole of what "reconnect" can mean. */ reconnect(node: NodeId): Promise; /** * Close the connection to a peer and stop dialling it, keeping the pairing. * * Holds until {@link reconnect} or a restart — the peer stays authorised, so this is "leave it * alone for now", and {@link unpair} is the stronger verb. Its `SwarmNode` row goes * `link: down`, with its agents still described. */ disconnect(node: NodeId): Promise; /** * Machines that proved who they are and have not been paired with. * * `dialled` says which question to ask: `true` is "this is what is at that address — add it?", * `false` is "this machine wants to join — allow it?". Same button, different question. */ strangers(): Promise; /** A node joined, left, changed what it is running, or asked to join. Redraw. */ onChange(cb: () => void): Disposable; onStream( cb: (e: { node: NodeId; session: string; event: StreamEvent }) => void, ): Disposable; } export interface RuntimePathApi { /** * Add a directory to search for plugins. * * Only effective before discovery runs, which means: from `init.ts`. That is the point — your * config is the thing that decides what else loads, exactly as `init.lua` is. */ add(path: string): Promise; list(): Promise; } /** * Completing a typed path. * * The runtime has no filesystem — deliberately — and a path field without completion is a path * field you type wrong. Narrow on purpose: directory names, one level, never file contents and * never a recursive walk. */ export interface PathApi { /** * Directories whose path begins with `prefix`, each with a trailing `/` so the answer can go * straight back into the field and be completed again. * * `~` expands against the home directory. A prefix with no `/` completes against the active * conversation's directory, which is what someone typing `src` means. * * `denied` is set when the list is empty *because the operating system refused*, rather than * because nothing matched. The two are the same empty array and they are not the same answer: * macOS grants folder access to the **terminal**, so `~/Documents` full of projects reads as * empty until somebody ticks a box, and a field that draws "no directory matches" over that * sends people to go and check a path that was right all along. When it is set it says which * folder, which application and which System Settings pane, and it is meant to be shown. */ complete(prefix: string): Promise<{ paths: string[]; denied?: string }>; } export interface TimerApi { /** * Run `fn` once, no sooner than `ms` from now. * * The returned `Disposable` cancels it, and **every timer is cancelled when your plugin * unloads** — which the global `setTimeout` cannot promise, because it has no idea who called it. * Prefer this one; reach for the global only when porting code that expects it. */ after(ms: number, fn: () => void): Disposable; /** Run `fn` repeatedly until disposed. */ every(ms: number, fn: () => void): Disposable; /** * Coalesce bursts: calling the returned function repeatedly runs `fn` once, `ms` after the last * call. The shape almost every "re-render after the tokens stop" wants. */ debounce(ms: number, fn: (...args: A) => void): ((...args: A) => void) & Disposable; } export interface Logger { info(msg: string): void; warn(msg: string): void; error(msg: string): void; } // --------------------------------------------------------------------------- // Implementation // --------------------------------------------------------------------------- interface Registered { commands: Map unknown>; tools: Map ToolResult | Promise>; hooks: Map HookOutcome | Promise>; providers: Map void, signal: { cancelled: boolean }) => unknown>; bufferListeners: Map void>>; optionListeners: Array<(e: { name: string; value: OptionValue }) => void>; /// The protocol version this plugin was handed, kept so a namespace can be rebuilt for a /// view long after `__createContext` returned. version: number; sessionListeners: Array<(e: { session: SessionId; view: ViewId }) => void>; viewOpenListeners: Array<(view: ViewId) => void>; viewCloseListeners: Array<(view: ViewId) => void>; selectionListeners: Array<(e: { selection: ModelSelection }) => void>; composerListeners: Array<(e: { text: string }) => void>; activityListeners: Array<(e: { session: SessionId; turn: string; activity: Activity }) => void>; varListeners: Array<(e: { scope: VarScope; key: string; value: unknown }) => void>; projectMovedListeners: Array<(e: { from: string; to: string }) => void>; swarmListeners: Array<() => void>; quotaListeners: Array<(snapshot: QuotaSnapshot) => void>; swarmStreamListeners: Array< (e: { node: NodeId; session: string; event: StreamEvent }) => void >; contributionListeners: Array<(e: { point: string }) => void>; highlightListeners: Array<(e: { names: string[] }) => void>; focusListeners: Array<(e: { win: WindowId | null }) => void>; viewListeners: Array<() => void>; shutdownListeners: Array<() => void>; /** * Listeners by event name, plus `null` for the ones that asked for everything. * * Filtered here rather than in the host on purpose: a subscription table on the far side of the * boundary is one the host can only ever guess is current, and getting it wrong means a plugin * silently stops hearing things. Broadcasting everything and matching a string is cheap. */ eventListeners: Map void>>; options: Set; /// Timer handles this plugin armed, cleared on unload. timers: Set; agentListeners: { [K in "turnStart" | "token" | "thinking" | "turnEnd" | "toolStart" | "toolEnd"]: Array<(e: never) => void> }; streams: Map; subscriptions: Disposable[]; } const plugins = new Map(); function reg(plugin: string): Registered { let r = plugins.get(plugin); if (!r) { r = { commands: new Map(), tools: new Map(), hooks: new Map(), providers: new Map(), bufferListeners: new Map(), optionListeners: [], version: 0, sessionListeners: [], viewOpenListeners: [], viewCloseListeners: [], selectionListeners: [], composerListeners: [], activityListeners: [], varListeners: [], projectMovedListeners: [], swarmListeners: [], quotaListeners: [], swarmStreamListeners: [], contributionListeners: [], highlightListeners: [], focusListeners: [], viewListeners: [], shutdownListeners: [], eventListeners: new Map(), options: new Set(), timers: new Set(), agentListeners: { turnStart: [], token: [], thinking: [], turnEnd: [], toolStart: [], toolEnd: [] }, streams: new Map(), subscriptions: [], }; plugins.set(plugin, r); } return r; } /** A listener on one event name, or on all of them when `name` is `null`. */ function eventListener( r: Registered, name: string | null, cb: (e: { name: string; data: unknown; from: string }) => void, ): Disposable { const list = r.eventListeners.get(name) ?? []; r.eventListeners.set(name, list); return listener(list, cb); } function listener(list: Array<(e: T) => void>, cb: (e: T) => void): Disposable { list.push(cb as (e: never) => void as (e: T) => void); return { dispose() { const i = list.indexOf(cb); if (i >= 0) list.splice(i, 1); }, }; } function floatConfig(o: FloatOptions = {}): FloatConfig { return { anchor: o.anchor ?? { kind: "screen" }, offset: o.offset ?? { row: 0, col: 0 }, width: o.width ?? { kind: "auto" }, height: o.height ?? { kind: "auto" }, z: o.z ?? 100, border: o.border ?? "rounded", border_hl: o.borderHl ?? null, title: o.title ?? null, footer: o.footer ?? null, close_on_blur: o.closeOnBlur ?? false, focusable: o.focusable ?? true, modal: o.modal ?? false, scroll: o.scroll ?? false, }; } function markOpts(o: MarkOptions = {}): ExtmarkOpts { return { end_col: o.endCol ?? null, hl_group: o.hlGroup ?? null, line_hl_group: o.lineHlGroup ?? null, virt_text: (o.virtText ?? []).map((v) => ({ text: v.text, hl_group: v.hlGroup ?? null })), virt_text_pos: o.virtTextPos ?? "eol", on_delete: o.onDelete ?? "clamp", priority: o.priority ?? 0, image: o.image ? { path: o.image.path, media_type: o.image.mediaType } : null, }; } /** * Build the whole `neosh` namespace over one pair of call functions. * * Called once per plugin with calls that say nothing about where they land, which is the ordinary * `neosh`: the host works out which terminal a window belongs in, and for anything done in answer * to a key that is exact. And again, per view, with calls that name it — which is what a command * handler is handed as its third argument. Inside a handler, `here.win.open(...)` is a panel in * the terminal the key was pressed in, and every other call on `here` is the call it always was. */ function build( plugin: string, version: number, r: ReturnType, view: ViewId | null, ): Neosh { // On the envelope rather than in each call: a call comes *from* a terminal, exactly as a key // press does, and it is the same fact for all of them. It matters for far more than opening a // window — which conversation is on screen, what is in the composer, what `session.list` marks // as the one you are in are all questions with an answer per terminal. const c = (x: ApiCall) => call(plugin, x, view ?? undefined); const n = (x: ApiCall) => notify(plugin, x, view ?? undefined); const api: Neosh = { version, notify(message, level) { n({ call: "notify", level: level ?? "info", message, kind: "reply" }); }, progress(key, message) { n({ call: "notify", level: "info", message, kind: "progress", key }); }, done(key) { n({ call: "notify_done", key }); }, async alert(title, message, opts) { // Awaited rather than fired and forgotten, unlike the three above: this is the one that can // be refused — for want of `notify` in the manifest — and a capability error nobody sees is // a plugin that silently never notifies. await c({ call: "alert", level: opts?.level ?? "info", title, message, session: opts?.session, }); }, async permit(capability) { return expect(await c({ call: "permission_check", capability }), "permission").decision; }, onViewAttached(cb) { return listener(r.viewListeners, cb); }, onShutdown(cb) { return listener(r.shutdownListeners, cb); }, async ask(questions) { return expect(await c({ call: "ask_user", questions }), "answers").answers ?? null; }, opt: { async declare(spec) { await c({ call: "opt_declare", spec }); r.options.add(spec.name); return { dispose: () => r.options.delete(spec.name) }; }, async get(name: string): Promise { const entry = expect(await c({ call: "opt_get", name }), "option").entry; if (!entry) throw new Error(`option ${name} is not declared`); return entry.value as T; }, async entry(name) { return expect(await c({ call: "opt_get", name }), "option").entry; }, async set(name, value) { await c({ call: "opt_set", name, value }); }, async reset(name) { await c({ call: "opt_reset", name }); }, async all() { return expect(await c({ call: "opt_all" }), "options").options; }, onChange(cb) { return listener(r.optionListeners, cb); }, }, edit: { async move(win, motion, opts) { await c({ call: "win_motion", win, motion, select: opts?.select ?? false }); }, async apply(win, edit) { await c({ call: "win_edit", win, edit }); }, async select(win, on) { await c({ call: "win_select", win, on }); }, async selectShape(win, shape) { await c({ call: "win_select_shape", win, shape }); }, async cursorShape(win, shape) { await c({ call: "win_cursor_shape", win, shape }); }, async selection(win) { return expect(await c({ call: "win_selection", win }), "text").text; }, async copy(text) { await c({ call: "clipboard_write", text }); }, }, path: { async complete(prefix) { const answer = expect(await c({ call: "path_complete", prefix }), "paths"); return { paths: answer.paths, denied: answer.denied ?? undefined }; }, }, state: { async get(key: string): Promise { const value = expect(await c({ call: "state_get", key }), "json").value; return (value ?? null) as T | null; }, async set(key, value) { await c({ call: "state_set", key, value }); }, async remove(key) { await c({ call: "state_delete", key }); }, }, vars: { async get(scope: VarScope, key: string): Promise { const value = expect(await c({ call: "var_get", scope, key }), "json").value; return (value ?? null) as T | null; }, async set(scope, key, value) { await c({ call: "var_set", scope, key, value }); }, async remove(scope, key) { await c({ call: "var_delete", scope, key }); }, async all(scope) { return expect(await c({ call: "var_all", scope }), "vars").vars; }, onChange(cb) { return listener(r.varListeners, cb); }, }, project: { onMove(cb) { return listener(r.projectMovedListeners, cb); }, }, ext: { async contribute(point, id, item, opts) { await c({ call: "ext_contribute", point, id, item, priority: opts?.priority ?? 0 }); return { dispose() { void c({ call: "ext_remove", point, id }); }, }; }, async remove(point, id) { await c({ call: "ext_remove", point, id }); }, async list(point: string) { const got = expect(await c({ call: "ext_list", point }), "contributions").contributions; return got as Array; }, onChange(cb) { return listener(r.contributionListeners, cb); }, async points() { return expect(await c({ call: "ext_points" }), "points").points; }, async plugins() { return expect(await c({ call: "plugin_list" }), "plugins").plugins; }, }, swarm: { async self() { return expect(await c({ call: "swarm_self" }), "swarm_self").node ?? null; }, async nodes() { return expect(await c({ call: "swarm_nodes" }), "swarm_nodes").nodes; }, async agents() { return expect(await c({ call: "swarm_agents" }), "swarm_agents").agents; }, async probe(addr) { const found = expect(await c({ call: "swarm_probe", addr }), "swarm_self").node; if (!found) throw new NeoshError({ kind: "not_found", what: addr }); return found; }, async pair(node, opts) { await c({ call: "swarm_pair", node, name: opts?.name ?? "", addr: opts?.addr ?? null, }); }, async unpair(node) { await c({ call: "swarm_unpair", node }); }, async rename(node, name) { await c({ call: "swarm_rename", node, name }); }, async reconnect(node) { await c({ call: "swarm_reconnect", node }); }, async disconnect(node) { await c({ call: "swarm_disconnect", node }); }, async strangers() { return expect(await c({ call: "swarm_strangers" }), "swarm_strangers").strangers; }, async hostsOf(project) { return expect(await c({ call: "swarm_hosts_of", project }), "names").names; }, async command(node, session, command) { const done = await c({ call: "swarm_command", node, session, command }); // Only `new_session` says anything, so an `ok` of any other shape is an older peer // answering a command that had nothing to report — which is a success, not a mismatch. return done.ok === "swarm_commanded" ? done.session ?? null : null; }, async browse(node, prefix) { return expect(await c({ call: "swarm_browse", node, prefix: prefix ?? "" }), "paths").paths; }, async shell(node, cwd) { await c({ call: "swarm_shell", node, cwd: cwd ?? null }); }, async subscribe(node, session) { await c({ call: "swarm_subscribe", node, session }); }, async unsubscribe(node, session) { await c({ call: "swarm_unsubscribe", node, session }); }, onChange(cb) { return listener(r.swarmListeners, cb); }, onStream(cb) { return listener(r.swarmStreamListeners, cb); }, }, update: { async check(force) { return expect( await c({ call: "update_check", force: force ?? false, local: false }), "update", ).update; }, async local() { return expect( await c({ call: "update_check", force: false, local: true }), "update", ).update; }, async apply() { return expect(await c({ call: "update_apply" }), "update_applied").outcome; }, async restart(force) { await c({ call: "update_restart", force: force ?? false }); }, }, quota: { async list() { return expect(await c({ call: "quota_list" }), "quotas").quotas; }, async refresh(instance) { await c({ call: "quota_refresh", instance: instance ?? null }); }, async report(snapshot) { await c({ call: "quota_report", snapshot }); }, async history(opts) { const got = await c({ call: "quota_history", instance: opts.instance ?? null, since: opts.since, until: opts.until, }); return expect(got, "quota_history").samples; }, async usage(opts) { const got = await c({ call: "usage_history", since: opts.since, until: opts.until, resolution: opts.resolution, time_zone: opts.timeZone ?? null, }); return expect(got, "usage_history").history; }, onChange(cb) { return listener(r.quotaListeners, cb); }, }, event: { async emit(name, data) { await c({ call: "event_emit", name, data: data ?? null }); }, on(name, cb, opts) { const kind = opts?.kind; return eventListener(r, name, (e) => { if (kind !== undefined) { const d = e.data as { kind?: unknown } | null | undefined; if (d?.kind !== kind) return; } cb({ data: e.data, from: e.from }); }); }, onAny(cb) { return eventListener(r, null, cb); }, }, timer: { after(ms, fn) { const id = setTimeout(() => { r.timers.delete(id); fn(); }, ms); r.timers.add(id); return { dispose() { clearTimeout(id); r.timers.delete(id); }, }; }, every(ms, fn) { const id = setInterval(fn, ms); r.timers.add(id); return { dispose() { clearInterval(id); r.timers.delete(id); }, }; }, debounce(ms: number, fn: (...args: A) => void) { let pending: number | undefined; const run = (...args: A) => { if (pending !== undefined) { clearTimeout(pending); r.timers.delete(pending); } const id = setTimeout(() => { pending = undefined; // Prune here too, not only on the re-arm and dispose paths: a debounce that settles is // the *normal* outcome, and leaving its handle behind grows the set once per burst for // the life of the plugin. r.timers.delete(id); fn(...args); }, ms); pending = id; r.timers.add(id); }; run.dispose = () => { if (pending !== undefined) { clearTimeout(pending); r.timers.delete(pending); pending = undefined; } }; return run; }, }, rtp: { async add(path) { await c({ call: "rtp_add", path }); }, async list() { return expect(await c({ call: "rtp_list" }), "paths").paths; }, }, log: { info: (message) => n({ call: "log", level: "info", message }), warn: (message) => n({ call: "log", level: "warn", message }), error: (message) => n({ call: "log", level: "error", message }), }, buf: { async create(opts) { return expect(await c({ call: "buf_create", name: opts?.name ?? null, scratch: opts?.scratch ?? false, kind: opts?.kind ?? null, }), "buf").buf; }, async lineCount(buf) { return expect(await c({ call: "buf_line_count", buf }), "count").n; }, async getLines(buf, start, end) { return expect(await c({ call: "buf_get_lines", buf, start, end }), "lines").lines; }, async setLines(buf, start, end, lines) { await c({ call: "buf_set_lines", buf, start, end, lines }); }, async render(buf, ns, start, end, rows) { await c({ call: "buf_render", buf, ns, start, end, lines: rows.map((r) => ({ text: r.text, marks: (r.marks ?? []).map((m) => ({ col: m.col, ...markOpts(m.opts) })), })), }); }, async appendText(buf, text) { await c({ call: "buf_append_text", buf, text }); }, async setName(buf, name) { await c({ call: "buf_set_name", buf, name }); }, async setKind(buf, kind) { await c({ call: "buf_set_kind", buf, kind }); }, async kind(buf) { return expect(await c({ call: "buf_get_kind", buf }), "maybe_text").text ?? null; }, async onChange(buf, cb) { await c({ call: "buf_attach", buf }); const list = r.bufferListeners.get(buf) ?? []; r.bufferListeners.set(buf, list); return listener(list, cb); }, }, pane: { async active(view) { const { tabs, active } = expect(await c({ call: "tab_list", view }), "tabs"); // The active tab, or the first — a terminal always has at least one, and a `tabs` that // came back empty is a workspace that has gone wrong in a way this cannot paper over. const tab = tabs.find((t) => t.id === active) ?? tabs[0]; if (!tab) throw new Error("this terminal has no tabs"); return tab.active_pane; }, async split(pane, dir) { const p = expect(await c({ call: "pane_split", pane, dir }), "pane").pane; if (p === null) throw new Error(`pane ${pane} could not be split`); return p; }, async close(pane) { await c({ call: "pane_close", pane }); }, async focus(pane) { await c({ call: "pane_focus", pane }); }, async focusDir(dir, view) { return expect(await c({ call: "pane_focus_dir", dir, view }), "pane").pane; }, async resize(pane, dir, delta) { await c({ call: "pane_resize", pane, dir, delta }); }, async swap(pane, withPane) { await c({ call: "pane_swap", pane, with: withPane }); }, async moveToEdge(pane, dir) { await c({ call: "pane_move_edge", pane, dir }); }, async equalize(view) { await c({ call: "pane_equalize", view }); }, }, tab: { async list(view) { const r = expect(await c({ call: "tab_list", view }), "tabs"); return { tabs: r.tabs, active: r.active }; }, async create(opts) { return expect( await c({ call: "tab_new", view: opts?.view, title: opts?.title, activate: opts?.activate ?? true, group: opts?.group, }), "tab", ).tab; }, async group(tab, group) { await c({ call: "tab_group", tab, group }); }, async close(tab) { await c({ call: "tab_close", tab }); }, async select(tab) { await c({ call: "tab_select", tab }); }, async step(delta, view) { await c({ call: "tab_step", delta, view }); }, async move(tab, to) { await c({ call: "tab_move", tab, to }); }, async rename(tab, title) { await c({ call: "tab_rename", tab, title }); }, }, win: { async open(buf, dock, opts) { const layout: WindowLayout = { kind: "docked", pane: opts?.pane ?? null, dock, size: opts?.size ?? null, gravity: opts?.gravity ?? "start", wrap: opts?.wrap ?? null, }; return expect(await c({ call: "win_open", buf, layout }), "win").win; }, async close(win) { await c({ call: "win_close", win }); }, async resize(win, size) { await c({ call: "win_resize", win, size }); }, async setBuf(win, buf) { await c({ call: "win_set_buf", win, buf }); }, async cursor(win) { const v = expect(await c({ call: "win_get_cursor", win }), "cursor"); return { row: v.row, col: v.col }; }, async setCursor(win, row, col) { await c({ call: "win_set_cursor", win, row, col }); }, async scrollTo(win, topLine) { await c({ call: "win_scroll_to", win, top_line: topLine }); }, async scroll(win, amount) { await c({ call: "win_scroll", win, amount }); }, async viewport(win) { return expect(await c({ call: "win_get_viewport", win }), "viewport").viewport ?? null; }, async list() { return expect(await c({ call: "win_list" }), "windows").windows; }, async ofKind(kind) { const windows = expect(await c({ call: "win_list" }), "windows").windows; return windows.filter((w) => w.kind === kind); }, async setHighlights(target, map) { const t: HlTarget = "win" in target ? { kind: "window", win: target.win } : { kind: "kind", name: target.kind }; await c({ call: "win_set_highlights", target: t, map }); }, }, float: { async open(buf, opts) { return expect(await c({ call: "float_open", buf, config: floatConfig(opts) }), "win").win; }, async configure(win, opts) { await c({ call: "float_configure", win, config: floatConfig(opts) }); }, async close(win) { await c({ call: "win_close", win }); }, }, ns: { async create(name) { return expect(await c({ call: "ns_create", name }), "ns").ns; }, async mark(ns, buf, row, col, opts) { return expect(await c({ call: "mark_set", ns, buf, row, col, opts: markOpts(opts) }), "mark").id; }, async getMark(ns, buf, id) { return expect(await c({ call: "mark_get", ns, buf, id }), "mark_info").info; }, async allMarks(ns, buf) { return expect(await c({ call: "mark_all", ns, buf }), "marks").marks; }, async delMark(ns, buf, id) { await c({ call: "mark_del", ns, buf, id }); }, async clear(ns, buf, start, end) { await c({ call: "mark_clear", ns, buf, start: start ?? null, end: end ?? null }); }, }, hl: { async define(name, def, opts) { const d: HighlightDef = "link" in def ? { kind: "link", to: def.link } : { kind: "spec", spec: def }; await c({ call: "hl_define", name, def: d, default: opts?.default ?? false }); }, async get(name) { const v = expect(await c({ call: "hl_get", name }), "highlight"); return { def: v.def ?? null, resolved: v.resolved ?? null }; }, async list() { return expect(await c({ call: "hl_list" }), "highlights").groups; }, async reset(name) { await c({ call: "hl_reset", name }); }, onChange(cb) { return listener(r.highlightListeners, cb); }, }, ui: { async claim(win, rect) { return expect(await c({ call: "surface_claim", win, rect }), "surface").surface; }, async put(surface, cells) { await c({ call: "surface_put", surface, cells }); }, async release(surface) { await c({ call: "surface_release", surface }); }, }, cmd: { async register(name, fn, opts) { await c({ call: "cmd_register", name, desc: opts?.desc ?? null }); r.commands.set(name, fn); return { dispose: () => { r.commands.delete(name); n({ call: "cmd_unregister", name }); }, }; }, async exec(name, args) { await c({ call: "cmd_exec", name, args: args ?? [] }); }, async call(name, args) { return expect(await c({ call: "cmd_call", name, args: args ?? [] }), "json").value as never; }, async list() { return expect(await c({ call: "cmd_list" }), "commands").commands; }, }, keymap: { async set(mode, lhs, command, opts) { await c({ call: "keymap_set", mode, lhs, command, scope: opts?.scope ?? null, desc: opts?.desc ?? null }); }, async del(mode, lhs, scope) { await c({ call: "keymap_del", mode, lhs, scope: scope ?? null }); }, async list(mode) { return expect(await c({ call: "keymap_list", mode: mode ?? null }), "keymaps").keymaps; }, async capture(win, command) { await c({ call: "keymap_capture", win, command }); return { dispose: () => n({ call: "keymap_release", win }) }; }, }, focus: { async push(win) { await c({ call: "focus_push", win }); }, async pop() { await c({ call: "focus_pop" }); }, async current() { return expect(await c({ call: "focus_current" }), "focused_win").win; }, onChange(cb) { return listener(r.focusListeners, cb); }, }, agent: { async send(text, opts) { await c({ call: "agent_send", text, images: opts?.images ?? [] }); }, async cancel() { await c({ call: "agent_cancel" }); }, async command(command, session) { const v = await c({ call: "agent_command", session: session ?? null, command }); return expect(v, "maybe_session").session; }, async selection() { return expect(await c({ call: "agent_get_selection" }), "selection").selection; }, async setSelection(selection) { await c({ call: "agent_set_selection", selection }); }, async listModels(instance, opts) { const v = await c({ call: "agent_list_models", instance: instance ?? null, refresh: opts?.refresh ?? false, }); return expect(v, "models").models; }, async listInstances() { return expect(await c({ call: "agent_list_instances" }), "instances").instances; }, async driverCommands() { return expect(await c({ call: "agent_driver_commands" }), "driver_commands").commands; }, async setDraft(text) { await c({ call: "chat_set_draft", text }); }, async attach(path) { const v = await c({ call: "chat_attach", path: path ?? null }); // Exactly one, because exactly one was asked for. An empty answer would mean the host // silently attached nothing, which it does not — it rejects. const [one] = expect(v, "attachments").attachments; if (!one) throw new Error("nothing was attached"); return one; }, async attachments() { return expect(await c({ call: "chat_attachments" }), "attachments").attachments; }, async detach(index) { const v = await c({ call: "chat_detach", index: index ?? null }); return expect(v, "attachments").attachments[0] ?? null; }, async detachAll() { return expect(await c({ call: "chat_detach_all" }), "attachments").attachments; }, async credentials() { return expect(await c({ call: "provider_credentials" }), "credentials").credentials; }, async setCredential(instance, opts) { // Does not settle until the prompt closes — the answer is what the user did. const v = await c({ call: "provider_set_credential", instance, replace: opts?.replace ?? false, }); return expect(v, "bool").value; }, async forgetCredential(instance) { await c({ call: "provider_forget_credential", instance }); }, onSelectionChange: (cb) => listener(r.selectionListeners, cb), onComposerChange: (cb) => listener(r.composerListeners, cb), onActivity: (cb) => listener(r.activityListeners, cb), onTurnStart: (cb) => listener(r.agentListeners.turnStart as Array<(e: { session: string; turn: string }) => void>, cb), onToken: (cb) => listener(r.agentListeners.token as Array<(e: { session: string; turn: string; text: string }) => void>, cb), onThinking: (cb) => listener(r.agentListeners.thinking as Array<(e: { session: string; turn: string; text: string }) => void>, cb), onTurnEnd: (cb) => listener( r.agentListeners.turnEnd as Array< (e: { session: string; turn: string; stopReason: StopReason; usage: Usage }) => void >, cb, ), onToolStart: (cb) => listener( r.agentListeners.toolStart as Array<(e: { session: string; turn: string; call: ToolCall }) => void>, cb, ), onToolEnd: (cb) => listener( r.agentListeners.toolEnd as Array< (e: { session: string; turn: string; call: ToolCall; result: ToolResult }) => void >, cb, ), }, tool: { async register(def, handler) { await c({ call: "tool_register", def: { name: def.name, description: def.description, input_schema: def.inputSchema, source: { kind: "builtin" }, }, }); r.tools.set(def.name, handler); return { dispose: () => { r.tools.delete(def.name); n({ call: "tool_unregister", name: def.name }); }, }; }, async list() { return expect(await c({ call: "tool_list" }), "tools").tools; }, }, hook: { async register(hook, fn, opts) { const blocking = opts?.blocking ?? false; await c({ call: "hook_register", hook, blocking, timeout_ms: opts?.timeoutMs ?? null }); r.hooks.set(hook, fn); return { dispose: () => { r.hooks.delete(hook); n({ call: "hook_unregister", hook }); }, }; }, }, git: { async status(opts) { return expect(await c({ call: "git_status", cwd: opts?.cwd ?? null }), "status").status; }, async branches(opts) { const v = await c({ call: "git_branches", include_remote: opts?.includeRemote ?? false, cwd: opts?.cwd ?? null, }); return expect(v, "branches").branches; }, async worktrees(opts) { const v = await c({ call: "git_worktrees", cwd: opts?.cwd ?? null }); return expect(v, "worktrees").worktrees; }, async log(limit) { return expect(await c({ call: "git_log", limit: limit ?? 20 }), "commits").commits; }, async diff(target, opts) { const v = await c({ call: "git_diff", target: target ?? { kind: "unstaged" }, stat: opts?.stat ?? false, }); return expect(v, "text").text; }, async defaultBranch() { return expect(await c({ call: "git_default_branch" }), "maybe_text").text ?? null; }, async createBranch(name, opts) { await c({ call: "git_create_branch", name, from: opts?.from ?? null }); }, async renameBranch(name, next, opts) { await c({ call: "git_rename_branch", old: name, new: next, cwd: opts?.cwd ?? null }); }, async checkout(rev) { await c({ call: "git_checkout", rev }); }, async stage(paths) { await c({ call: "git_stage", paths: paths ?? [] }); }, async unstage(paths) { await c({ call: "git_unstage", paths: paths ?? [] }); }, async commit(message) { return expect(await c({ call: "git_commit", message }), "commit").commit; }, async addWorktree(path, branch, opts) { await c({ call: "git_add_worktree", path, branch, create: opts?.create ?? false, cwd: opts?.cwd ?? null, }); }, async fetch(opts) { const v = await c({ call: "git_fetch", cwd: opts?.cwd ?? null }); return expect(v, "status").status; }, async pulls(opts) { const v = await c({ call: "forge_pulls", cwd: opts?.cwd ?? null }); return expect(v, "pulls").pulls; }, async pull(opts) { const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null, rebase: opts?.rebase ?? false }); return expect(v, "text").text; }, async removeWorktree(path, opts) { await c({ call: "git_remove_worktree", path, force: opts?.force ?? false, cwd: opts?.cwd ?? null, }); }, async moveWorktree(path, dest, opts) { await c({ call: "git_move_worktree", path, dest, cwd: opts?.cwd ?? null }); }, async clone(url, path) { const v = await c({ call: "git_clone", url, path }); return expect(v, "text").text; }, }, gen: { async complete(prompt, opts) { const v = await c({ call: "gen_complete", prompt, system: opts?.system ?? null, json: false, selection: opts?.selection ?? null, }); return expect(v, "text").text; }, async json(prompt, opts) { const v = await c({ call: "gen_complete", prompt, system: opts?.system ?? null, json: true, selection: opts?.selection ?? null, }); return expect(v, "json").value as never; }, async field(prompt, key, opts) { const v = await c({ call: "gen_complete", prompt, system: opts?.system ?? null, json: true, field: key, selection: opts?.selection ?? null, }); const value = (expect(v, "json").value as Record)?.[key]; // A key that came back as something other than a non-empty string is an answer to a // different question, and the caller is about to name a branch after it. if (typeof value !== "string" || value.trim() === "") { throw new Error(`the model returned no ${key}`); } return value.trim(); }, }, view: { async list() { return expect(await c({ call: "view_list" }), "views").views; }, async current() { const views = expect(await c({ call: "view_list" }), "views").views; return views.find((v) => v.current) ?? null; }, at: (id) => build(plugin, version, r, id), onOpen(cb) { // The terminals that were already here, as well as the ones still to come. A plugin loaded // after them missed their arrival, and "open a panel in every view" would otherwise mean // every view *from now on* — which is every view except the one you are sitting in. // // Announced once each: a view that arrives while the list is in flight arrives by the // event too, and `seen` is what stops it being announced twice. const seen = new Set(); const announce = (id: ViewId) => { if (seen.has(id)) return; seen.add(id); cb(id); }; const d = listener(r.viewOpenListeners, announce); void c({ call: "view_list" }) .then((v) => { for (const info of expect(v, "views").views) announce(info.view); }) .catch(() => {}); return d; }, onClose: (cb) => listener(r.viewCloseListeners, cb), }, session: { async list(opts) { const v = await c({ call: "session_list", include_archived: opts?.includeArchived ?? false, }); return expect(v, "sessions").sessions; }, async current() { return expect(await c({ call: "session_current" }), "session").session; }, async create(opts) { const v = await c({ call: "session_new", cwd: opts?.cwd ?? null, title: opts?.title ?? null, activate: opts?.activate ?? true, }); return expect(v, "session").session; }, async switch(session) { await c({ call: "session_switch", session }); }, async close(session) { await c({ call: "session_close", session }); }, async rename(session, title) { await c({ call: "session_rename", session, title }); }, async archive(session, archived) { await c({ call: "session_archive", session, archived: archived ?? true }); }, async stored() { return expect(await c({ call: "sessions_stored" }), "sessions").sessions; }, async messages(session) { const v = await c({ call: "session_messages", session: session ?? null }); return expect(v, "messages").messages; }, onChange: (cb) => listener(r.sessionListeners, cb), }, permission: { async mode() { return expect(await c({ call: "permission_get_mode" }), "permission_mode").mode; }, async setMode(mode) { return expect(await c({ call: "permission_set_mode", mode }), "permission_mode").mode; }, }, hint: { async set(key, hint) { await c({ call: "hint_set", key, hint: { keys: hint.keys, label: hint.label, priority: hint.priority ?? 0 }, }); }, async clear(key) { await c({ call: "hint_clear", key }); }, }, status: { async set(key, segment) { await c({ call: "status_set", key, segment: { text: segment.text, short: segment.short ?? null, keys: segment.keys ?? null, hl: segment.hl ?? null, align: segment.align ?? "left", priority: segment.priority ?? 0, }, }); }, async clear(key) { await c({ call: "status_clear", key }); }, }, provider: { async register(driver, instances, handler, opts) { await c({ call: "provider_register_driver", driver, instances, agent_loop: opts?.agentLoop ?? false, }); r.providers.set(driver, handler); return { dispose: () => r.providers.delete(driver) }; }, }, }; return api; } /** Build the API object handed to one plugin. Internal; the host calls this. */ export function __createContext(plugin: string, config: unknown, version: number): PluginContext { const r = reg(plugin); r.version = version; return { neosh: build(plugin, version, r, null), pluginId: plugin, config, subscriptions: r.subscriptions, }; } /** Route one host message. Internal; the host's bootstrap calls this. */ export async function __dispatch(plugin: string, msg: Record): Promise { const r = reg(plugin); if (msg.type === "response") { settle(msg.id as string, msg.response as ApiResponse); return; } if (msg.type === "request") { const id = msg.id as string; const req = msg.request as Record; const respond = (response: unknown) => send({ type: "plugin", plugin, msg: { type: "response", id, response } }); try { if (req.type === "run_tool") { const h = r.tools.get(req.name as string); if (!h) { respond({ type: "error", message: `plugin ${plugin} has no tool ${req.name}` }); return; } respond({ type: "tool", result: await h(req.input) }); } else if (req.type === "command") { const name = req.name as string; const h = r.commands.get(name); if (!h) { respond({ type: "error", message: `plugin ${plugin} has no command ${name}` }); return; } const value = await h((req.args as string[]) ?? [], undefined); // `undefined` is not JSON; a handler that returned nothing answers `null`. respond({ type: "command", value: value === undefined ? null : value }); } else if (req.type === "hook") { const h = r.hooks.get(req.hook as HookName); // A hook the plugin no longer has must not block the action: continue, do not veto. const outcome: HookOutcome = h ? await h(req.payload as HookPayload) : { action: "continue" }; respond({ type: "hook", outcome }); } else if (req.type === "provider_stream") { const streamId = req.stream as string; const tr = req.request as TurnRequest; const h = r.providers.get(tr.selection.instance); const byDriver = h ?? [...r.providers.values()][0]; if (!byDriver) { respond({ type: "error", message: `plugin ${plugin} has no provider` }); return; } const signal = { cancelled: false }; r.streams.set(streamId, signal); // Answer immediately: a stream cannot be a return value across this boundary. respond({ type: "provider_accepted" }); void (async () => { try { await byDriver(tr, (e) => notify(plugin, { call: "provider_emit", stream: streamId, event: e }), signal); } finally { r.streams.delete(streamId); } })(); } else { respond({ type: "error", message: `unknown request ${String(req.type)}` }); } } catch (e) { respond({ type: "error", message: e instanceof Error ? e.message : String(e) }); } return; } if (msg.type === "event") { const ev = msg.event as PluginEvent; try { await dispatchEvent(plugin, r, ev); } catch (e) { // A listener that throws is one plugin's bug, and it used to be every plugin's: an // unhandled rejection stops the runtime, and the runtime is shared. Reported and survived. const what = e instanceof Error ? (e.stack ?? e.message) : String(e); notify(plugin, { call: "log", level: "error", message: `handling ${ev.type}: ${what}` }); } return; } } async function dispatchEvent( plugin: string, r: ReturnType, ev: PluginEvent, ): Promise { { switch (ev.type) { case "command_invoked": { const h = r.commands.get(ev.name); if (!h) break; // The third argument is the whole namespace bound to the terminal the key was pressed in. // A handler that opens a panel writes `here.win.open(...)` and it lands where the person // pressing the key is looking, without having to say so or to know that views exist. const here = ev.key ? build(plugin, r.version, r, ev.key.view) : undefined; await h(ev.args ?? [], ev.key ?? undefined, here); break; } case "buffer_changed": { for (const cb of r.bufferListeners.get(ev.buf) ?? []) { cb({ buf: ev.buf, start: ev.start, oldEnd: ev.old_end, newEnd: ev.new_end }); } break; } case "turn_started": for (const cb of r.agentListeners.turnStart) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn }); break; case "token": for (const cb of r.agentListeners.token) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text }); break; case "thinking_token": for (const cb of r.agentListeners.thinking) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, text: ev.text }); break; case "turn_ended": for (const cb of r.agentListeners.turnEnd) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, stopReason: ev.stop_reason, usage: ev.usage, }); break; case "tool_started": for (const cb of r.agentListeners.toolStart) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, call: ev.call }); break; case "tool_finished": for (const cb of r.agentListeners.toolEnd) (cb as (e: unknown) => void)({ session: ev.session, turn: ev.turn, call: ev.call, result: ev.result, }); break; case "hook_observed": { const h = r.hooks.get(ev.hook); if (h) await h(ev.payload); break; } case "provider_cancel": { const s = r.streams.get(ev.stream); if (s) s.cancelled = true; break; } case "option_changed": for (const cb of r.optionListeners) cb({ name: ev.name, value: ev.value }); break; case "session_changed": for (const cb of r.sessionListeners) cb({ session: ev.session, view: ev.view }); break; case "view_attached": for (const cb of r.viewOpenListeners) cb(ev.view); break; case "view_closed": for (const cb of r.viewCloseListeners) cb(ev.view); break; case "selection_changed": for (const cb of r.selectionListeners) cb({ selection: ev.selection }); break; case "composer_changed": for (const cb of r.composerListeners) cb({ text: ev.text }); break; case "activity": for (const cb of r.activityListeners) cb({ session: ev.session, turn: ev.turn, activity: ev.activity }); break; case "var_changed": for (const cb of r.varListeners) cb({ scope: ev.scope, key: ev.key, value: ev.value }); break; case "project_moved": for (const cb of r.projectMovedListeners) cb({ from: ev.from, to: ev.to }); break; case "quota": for (const cb of [...r.quotaListeners]) cb(ev.snapshot); break; case "swarm_changed": for (const cb of [...r.swarmListeners]) cb(); break; case "swarm_stream": for (const cb of [...r.swarmStreamListeners]) { cb({ node: ev.node, session: ev.session, event: ev.event }); } break; case "contributions_changed": for (const cb of r.contributionListeners) cb({ point: ev.point }); break; case "focus_changed": for (const cb of r.focusListeners) cb({ win: ev.win ?? null }); break; case "view_attached": for (const cb of r.viewListeners) cb(); break; case "shutdown": for (const cb of r.shutdownListeners) cb(); break; case "highlight_changed": for (const cb of r.highlightListeners) cb({ names: ev.names }); break; case "event": { // Copied before iterating: a listener that unsubscribes itself — the ordinary shape of // "wait for the thing to happen once" — would otherwise shorten the array underneath the // loop and skip whoever was next. const named = [...(r.eventListeners.get(ev.name) ?? [])]; const all = [...(r.eventListeners.get(null) ?? [])]; const e = { name: ev.name, data: ev.data ?? null, from: ev.from }; for (const cb of named) cb(e); for (const cb of all) cb(e); break; } } } } /** Dispose everything a plugin registered. Internal. */ export function __teardown(plugin: string): void { const r = plugins.get(plugin); if (!r) return; // Before the disposers: a timer that fires mid-teardown would call into a plugin that is halfway // gone. for (const id of r.timers) { clearTimeout(id); } r.timers.clear(); for (const d of r.subscriptions) { try { d.dispose(); } catch { // A failing disposer must not stop the rest from running. } } plugins.delete(plugin); } ``` ==================== # The @neosh/api/ui widgets (plugins/api/src/ui.ts) ==================== ```ts /** * `@neosh/api/ui` — widgets built on the public API. * * Nothing here is privileged. Every line uses the same surface a third-party plugin has, which is * the point: if a picker could not be written this way, the API would be missing something. The * model switcher, the branch picker and the command palette are all this file plus a list. * * Import as: * * ```ts * import { picker, confirm, prompt } from "@neosh/api/ui"; * ``` */ import { byteLength, byteOffsets, clipToWidth, padToWidth, width } from "@neosh/api"; import type { BufferId, Contribution, Disposable, DrawnMark, DrawnRow, FileChange, FileState, FloatOptions, KeyContext, MarkOptions, Neosh, SessionInfo, WindowId, WorktreeInfo, } from "@neosh/api"; /** * The buffer kinds the shared widgets publish. * * A picker used to have no kind, which by the surface rule made it the one panel in the * workspace you could only replace and never extend. With one, `keymap.set` at `buf_kind` scope * binds inside every picker at once, `win.ofKind` finds the open one, and `win.setHighlights` * restyles them all. */ export const KIND_PICKER = "neosh.picker"; export const KIND_CONFIRM = "neosh.confirm"; export const KIND_PROMPT = "neosh.prompt"; // --------------------------------------------------------------------------- // Fuzzy matching // --------------------------------------------------------------------------- /** Where a query matched, so the caller can highlight it. */ export interface Match { score: number; /** * Indices of matched **code points**, ascending — not byte offsets and not UTF-16 indices. * * Code points because that is the unit a user perceives as "a character"; convert with * `byteOffsets` before handing them to `ns.mark`, which speaks bytes. */ positions: number[]; } /** * Subsequence match with a bias toward starts of words and runs of adjacent characters. * * Deliberately simple and deliberately *stable*: a picker that reorders under your fingers as you * type one more character is worse than one that ranks imperfectly. */ export function fuzzy(candidate: string, query: string): Match | null { if (query === "") return { score: 0, positions: [] }; // Code points, not UTF-16 units: indexing a string directly splits an emoji in half and reports // a position no later stage can use. const hay = Array.from(candidate.toLowerCase()); const needle = Array.from(query.toLowerCase()); const positions: number[] = []; let score = 0; let from = 0; let previous = -2; for (const ch of needle) { if (ch === " ") continue; // spaces separate terms rather than needing to match const at = hay.indexOf(ch, from); if (at < 0) return null; positions.push(at); // Adjacent characters are worth much more than scattered ones, and a match at a word boundary // more still — "gs" should find "git status" ahead of "goals". if (at === previous + 1) score += 8; if (at === 0 || /[\s/_.\-]/.test(hay[at - 1] ?? "")) score += 6; score += 1; previous = at; from = at + 1; } // Shorter candidates win ties: an exact short name should beat a long one that contains it. score -= Math.floor(hay.length / 32); return { score, positions }; } // --------------------------------------------------------------------------- // Picker // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Widget keys // --------------------------------------------------------------------------- /** * What a key means to a widget. * * Named actions rather than keys, so a binding is a setting: `ui.keys.next` is a space-separated * list in Neovim notation, and adding `` to it is one line of config rather than a fork of * this file. */ export type WidgetAction = | "next" | "prev" | "page_down" | "page_up" | "first" | "last" | "accept" | "dismiss" | "complete" | "clear" | "delete_word" /** Move to the next pane of a two-pane widget — the provider rail and the list beside it. */ | "pane_next" | "pane_prev"; const ACTIONS: WidgetAction[] = [ "next", "prev", "page_down", "page_up", "first", "last", "accept", "dismiss", "complete", "clear", "delete_word", "pane_next", "pane_prev", ]; /** One parsed key: a code kind, the character if it is one, and the modifiers that must match. */ interface KeySpec { kind: string; c?: string; ctrl: boolean; alt: boolean; shift: boolean; /** The notation it came from, so it can be bound window-scoped as well as matched. */ lhs: string; } /** * Parse one key in Neovim notation. * * The same notation `keymap.set` takes, so what a user writes in `ui.keys.next` looks like every * other binding they have written. Unparseable entries are dropped rather than thrown: a typo in a * setting should cost you that key, not the picker. */ function parseKey(spec: string): KeySpec | null { const trimmed = spec.trim(); if (trimmed === "") return null; if (!trimmed.startsWith("<") || !trimmed.endsWith(">")) { return trimmed.length === 1 ? { kind: "char", c: trimmed, ctrl: false, alt: false, shift: false, lhs: trimmed } : null; } let inner = trimmed.slice(1, -1); const out: KeySpec = { kind: "", ctrl: false, alt: false, shift: false, lhs: trimmed }; for (;;) { const m = /^([CSAMD])-/i.exec(inner); if (!m) break; const which = m[1]!.toUpperCase(); if (which === "C") out.ctrl = true; else if (which === "S") out.shift = true; else if (which === "A" || which === "M") out.alt = true; inner = inner.slice(2); } const named: Record = { cr: "enter", enter: "enter", tab: "tab", bs: "backspace", del: "delete", esc: "esc", up: "up", down: "down", left: "left", right: "right", home: "home", end: "end", pageup: "page_up", pagedown: "page_down", space: "char", lt: "char", insert: "insert", }; const lower = inner.toLowerCase(); if (lower in named) { out.kind = named[lower]!; if (lower === "space") out.c = " "; if (lower === "lt") out.c = "<"; // `` is not tab with a modifier — a terminal sends a wholly different sequence, which // arrives as its own code. Written the way everyone writes it, matched the way it arrives. if (out.kind === "tab" && out.shift) { out.kind = "back_tab"; out.shift = false; } return out; } if (inner.length === 1) { out.kind = "char"; out.c = inner; return out; } return null; } function matches(spec: KeySpec, key: KeyContext["key"]): boolean { if (key.code.kind !== spec.kind) return false; if (spec.kind === "char") { const c = key.code.kind === "char" ? key.code.c : ""; // Case-insensitively for control chords, because a terminal reports `` and `` // differently depending on whether shift happened to be down. if (spec.ctrl ? c.toLowerCase() !== (spec.c ?? "").toLowerCase() : c !== spec.c) return false; } if (key.mods.ctrl !== spec.ctrl || key.mods.alt !== spec.alt) return false; // Shift is only required when asked for: a terminal sets it for capital letters and for nothing // else consistently, so demanding its absence would break ``. return !spec.shift || key.mods.shift; } let bindings: Map | null = null; /** * Read the key settings once per session, and again whenever one changes. * * Cached because a picker asks this on every keystroke and an option read is a round trip to the * host. Invalidated by the change event rather than by a timer, so a rebinding takes effect on the * next key rather than the next minute. */ async function widgetKeys(neosh: Neosh): Promise> { if (bindings) return bindings; const built = new Map(); await Promise.all( ACTIONS.map(async (action) => { const raw = await neosh.opt.get(`ui.keys.${action}`).catch(() => ""); const specs = (raw ?? "") .split(/\s+/) .map(parseKey) .filter((k): k is KeySpec => k !== null); built.set(action, specs); }), ); bindings = built; return built; } let watching = false; /** * Start following `ui.keys.*` changes. Idempotent; called by every widget before it opens. * * The listener is never disposed on purpose — it belongs to the module, not to the widget that * happened to open first, and disposing it when that widget closed would leave the cache stale for * everyone else. */ function watchKeys(neosh: Neosh): void { if (watching) return; watching = true; neosh.opt.onChange((e) => { if (e.name.startsWith("ui.keys.")) bindings = null; }); } /** * How to write the key bound to an action, the way a person would press it. * * Read from the setting rather than hard-coded, so a rebinding shows up in the affordance too — * the whole point of putting the key on screen is that it is the key that works. */ function keyLabel(keys: Map, action: WidgetAction): string { const spec = keys.get(action)?.[0]; if (!spec) return ""; // The first key of `ui.keys.*` is the one that reaches this — which is why the defaults lead // with the chord and keep the arrow behind it. return prettyKey(spec.lhs); } /** * A key the way a person would press it, from the way a keymap spells it. * * `` is `^N` — not `^n`: nobody presses shift to send it, and the capital is how every * terminal program has written a chord since curses. `` is `↵`, `` is `esc`, `` is * `<`. For a legend, which is a promise about a keyboard: it prints what the registry holds, so a * rebinding shows the letter that works. */ export function prettyKey(lhs: string): string { const pretty: Record = { "": "⇥", "": "⇧⇥", "": "←", "": "→", "": "↑", "": "↓", "": "↵", "": "esc", "": "space", "": "⌫", "": "<", "": "pgup", "": "pgdn", }; return ( pretty[lhs] ?? lhs.replace(/^$/, (_, c: string) => `^${c.toUpperCase()}`).replace(/^<|>$/g, "") ); } /** * Which action a key press means, or `null` if it is ordinary input. * * `only` is the set of actions the asking widget can actually carry out, and it is not an * optimisation. Two actions share `` on purpose — `complete` in a field with suggestions * under it, `pane_next` in a widget with two panes — on the reasoning that no widget has both. It * does not have both, but it does *resolve* both: without this filter the first one in `ACTIONS` * wins for everybody, and `` in the model picker resolved to a completion the picker has no * case for, so it silently did nothing at all and the second pane had no way in. */ function actionFor( keys: Map, key: KeyContext["key"], only?: readonly WidgetAction[], ): WidgetAction | null { for (const action of only ?? ACTIONS) { for (const spec of keys.get(action) ?? []) { if (matches(spec, key)) return action; } } return null; } /** * The key strip a single-pane picker gets unless the caller writes its own. * * Built from the bindings rather than written out, because the point of putting a key on screen is * that it is the key that works — a legend that goes stale the moment somebody rebinds `ui.keys.*` * is worse than no legend, since it is believed. */ function defaultHints(keys: Map, filtering: boolean): string { const parts = [ `${keyLabel(keys, "accept")} choose`, `${keyLabel(keys, "prev")}/${keyLabel(keys, "next")} move`, ]; if (filtering) parts.push("type to filter"); parts.push(`${keyLabel(keys, "dismiss")} close`); return parts.join(" "); } /** What a two-pane widget answers to. Notably not `complete`, whose key it shares. */ const RAIL_ACTIONS: readonly WidgetAction[] = [ "dismiss", "accept", "pane_next", "pane_prev", "next", "prev", "page_down", "page_up", "first", "last", "clear", "delete_word", ]; export interface PickerItem { /** What the user reads and what the filter matches against. */ label: string; /** Dimmed, right of the label. A path, a description, a model id. */ detail?: string; /** Extra text the filter should match but that is not shown. */ keywords?: string; /** * One column of glyph before the label, coloured by `hl`. * * What it is for is telling *kinds* of row apart at a glance — a branch from a machine from a * directory — in a list where the labels alone read as one undifferentiated column. A picker * where only some rows have one still aligns: the ones without get the same gutter, so the * labels line up and the icons read as a column rather than as ragged punctuation. * * **One column.** The runtime cannot measure display width — only `neosh-tui` can — so a * two-column emoji here shifts that row's label right by one. Highlighting stays correct * regardless, because every offset is computed in bytes from the text actually written. */ icon?: string; /** * The highlight group for `icon`. A palette name — `Git.Branch`, `Diagnostic.Ok`, * `Sidebar.Remote` — never a colour, so a row follows the theme like everything else. */ hl?: string; value: T; } export interface PickerOptions { title?: string; /** Shown when the list is empty or nothing matches. */ placeholder?: string; /** Start with the cursor on this index. */ selected?: number; /** Rows of list to show, before the title and filter lines. */ height?: number; width?: number; /** * Called whenever the highlighted row changes. Use for a live preview — an "intelligence" picker * that describes each effort level, a branch picker that shows the tip commit. */ onHighlight?(item: PickerItem, index: number): void; /** * Where the rows come from, when they depend on what has been typed. * * With a source, the list is *replaced* on every keystroke instead of fuzzy-filtered — which is * what you want when the candidates cannot all be fetched up front: a directory listing, a * search, anything that asks something else a question. Without one, `items` is filtered locally. * * Calls are debounced and the answer is dropped if you have typed again since, so a slow source * cannot make the list flicker backwards. */ source?(query: string): Promise[]>; /** * What the filter starts with. * * A function is asked at the last possible moment — once the picker is on screen and holds the * keyboard — rather than when it was called. That matters for anything completing a field that * is still being typed into: opening this takes several round-trips, every keystroke in that * window goes to the field, and a picker seeded with what was there *before* them shows an * unfiltered list with the wrong row under the accept key. Typing `/compact` quickly and * pressing `↵` ran the first command in the list, which was not `compact`. */ query?: string | (() => string); /** * Whether there is a filter line at all. On by default. * * Off for a list you pick from rather than search — a yes/no question does not have a text field * in it, and drawing one invites the reader to type into something that will not answer. */ filter?: boolean; /** * What `accept` resolves to when nothing is highlighted, given what was typed. * * Lets a picker double as a field: a path picker offers completions but must still accept a path * you typed in full, which is not one of them. */ freeform?(query: string): T | null; /** * The key strip at the foot. One line. * * Written out by default, from the keys actually bound, so a rebinding shows up here rather than * making the row a lie. Pass `""` for a picker with nothing worth saying — a two-row yes/no * question is not helped by being told that `↵` chooses. */ hints?: string; /** * Where the float goes. The middle of the screen unless you say otherwise. * * `{ kind: "dock", dock: "bottom" }` with a negative row offset is how a *completion* is placed: * against the message field, above it, rather than over the transcript it has nothing to do with. */ anchor?: FloatOptions["anchor"]; /** Shifted from the anchor. Negative rows go up. */ offset?: { row: number; col: number }; /** * How the filter matches. * * `"fuzzy"` — the default — scores a subsequence match over the label *and* `keywords`, which is * what you want in a search box: you are looking for something and half-remember a word from its * description. * * `"name"` matches the start of the label first, then anywhere in it, and never looks at * `keywords` at all. That is what a **command menu** needs, and the difference is not cosmetic. * Typing `compact` into a fuzzy list of every command matches half of them — `c`…`o`…`m`…`p`… * are letters that occur in that order in almost any English sentence, and descriptions are * sentences — so the top row is something unrelated and `↵` runs it. A menu whose accept key * does an arbitrary thing is worse than one with no matching at all. */ match?: "fuzzy" | "name"; /** * The filter changed. * * The hook that lets a picker stay in step with something outside itself — a completion that * mirrors what is typed back into the composer, so the field says what you typed and dismissing * the list leaves it there. */ onQuery?(query: string): void; /** * Keys `onKey` wants that a *binding elsewhere* would otherwise take. * * The raw capture only receives what no keymap claimed, so a caller reaching for `` gets * nothing — that key belongs to the composer. Listing it here binds it to this widget for as long * as the widget is open, and drops it again with the window. * * A picker with a filter should only ever ask for chords. A bare letter taken here is a letter the * filter can never contain, which is how a list of conversations ends up unable to search for one * with an `x` in its name. */ ownKeys?: readonly string[]; /** * A key the caller wants for itself, checked before the widget's own. * * Return `"close"` to dismiss, `"reload"` to re-read the rows, `"handled"` to redraw, and nothing * at all to let the widget have the key. This is what puts a verb on a row — unarchive, delete, * rename — without a second modal in front of the list you were reading. * * `"reload"` re-runs `source` when there is one, and otherwise re-ranks `items`: the very array * you passed in, so a caller that mutates it in place gets a list that has caught up with what it * just did. A row acted on is a row that has to leave, and closing the list and opening it again * loses your place in it. */ onKey?( key: KeyContext, ctx: { item: T | undefined; query: string }, ): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined; /** * Watch something outside the picker, and reload the rows when it moves. * * Called once as the picker opens; the `Disposable` it returns is disposed with it. Calling * `reload` does what `onKey`'s `"reload"` does — re-run `source`, or re-rank `items`, the very * array you passed in — for a change no key caused: a peer connecting while the list of * computers is open is a row that should change under you, not on the next press. * * ```ts * subscribe: (reload) => neosh.swarm.onChange(reload) * ``` */ subscribe?(reload: () => void): Disposable; } const NS = "neosh.ui.picker"; /** * The row marker, and its width **in bytes**. * * `\u276f` is one column wide but three bytes in UTF-8, so the two markers are the same width on * screen and different lengths in the buffer. Every mark offset past the marker is computed from * the actual prefix rather than from its display width — the exact confusion this API's byte * columns exist to prevent. */ const CURSOR_MARKER = "\u276f "; const BLANK_MARKER = " "; /** * A filterable list in a float. Resolves to the chosen value, or `null` if dismissed. * * Keys: printable characters filter, `` deletes, `↑`/`↓` and ``/`` move, `` * accepts, `` dismisses. The float is **modal**, so nothing else reaches the workspace while * it is up — `^N` over an open picker used to start a new conversation behind it — bar the keys in * `ui.modal_escape_keys`, which is `` and `` unless you have said otherwise. * * Only one picker may be open per plugin at a time; opening a second dismisses the first, because * two modal lists competing for the keyboard is never what was meant. */ export async function picker( neosh: Neosh, items: PickerItem[], opts: PickerOptions = {}, ): Promise { // What the list would like. What it *gets* is settled by `measure` below, once the frontend has // said how much of the screen there was — a picker asking for twelve rows on a sixteen-row // terminal is not a twelve-row picker, and every number this widget computes from `rows` was // wrong by the difference until it asked. let rows = Math.max(1, opts.height ?? 12); const width = Math.max(20, opts.width ?? 64); const filtering = opts.filter !== false; const buf = await neosh.buf.create({ name: `[${opts.title ?? "picker"}]`, scratch: true, kind: KIND_PICKER }); const ns = await neosh.ns.create(NS); watchKeys(neosh); const keys = await widgetKeys(neosh); // A picker with no keys on it is a modal you have to guess your way out of. Every one of these // is a list you arrived at by pressing something, and none of them said what to press next. const hints = opts.hints ?? defaultHints(keys, filtering); const win = await neosh.float.open(buf, { anchor: opts.anchor ?? { kind: "screen" }, offset: opts.offset, width: { kind: "fixed", n: width }, // Exactly the rows that get drawn: the list, plus a filter line when there is one and a title // when there is one. The key strip is not among them any more — it is on the bottom border, // where it costs no row and cannot be the row that does not fit. It was a row, and on a // sixteen-row terminal it was reliably the one clipped: the strip that says how to get out. height: { kind: "fixed", n: rows + (filtering ? 1 : 0) + (opts.title ? 1 : 0) }, border: "rounded", footer: hints === "" ? undefined : ` ${hints} `, focusable: true, closeOnBlur: true, // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`, // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one, // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that // fails to bind a way out is never a terminal somebody has to kill: see // `ui.modal_escape_keys`. modal: true, z: 200, }); let query = typeof opts.query === "string" ? opts.query : ""; let cursor = Math.min(Math.max(0, opts.selected ?? 0), Math.max(0, items.length - 1)); let visible = items.map((item, index) => ({ item, index, positions: [] as number[] })); let top = 0; const rank = (pool: PickerItem[], q: string) => { const scored = pool .map((item, index) => { if (opts.match === "name") return byName(item, index, q); const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label; const m = fuzzy(hay, q); return m ? { item, index, positions: m.positions, score: m.score } : null; }) .filter((x): x is NonNullable => x !== null); // Stable within equal scores: the original order carries meaning (recency, configured order), // and re-sorting it away makes the list jump for no visible reason. scored.sort((a, b) => b.score - a.score || a.index - b.index); return scored; }; const refilter = () => { visible = rank(items, query); cursor = Math.min(cursor, Math.max(0, visible.length - 1)); }; /** * A row matched by the start of its name, then by any part of it. * * Two tiers and nothing else, because the whole point of this mode is that it is *predictable*: * you can tell from what you have typed which row you are about to accept. Longer names score * lower within a tier so an exact `git.diff` outranks `git.diff.staged`, and the highlighted * positions are the run that matched, which is where the eye is already looking. */ function byName(item: PickerItem, index: number, q: string) { if (!q) return { item, index, positions: [] as number[], score: 0 }; const label = item.label.toLowerCase(); const needle = q.toLowerCase(); const at = label.indexOf(needle); if (at < 0) return null; const positions = Array.from({ length: needle.length }, (_, k) => at + k); return { item, index, positions, score: (at === 0 ? 1000 : 500) - label.length }; } // Which fetch is current. A slow source answering after you have typed again would replace a // newer list with an older one, and the row under your finger would change out from under it. let generation = 0; // The fetch in flight, so accepting can wait for it. Typing faster than the source answers is // ordinary — anyone pasting a path does it — and taking the row that was under the cursor two // keystrokes ago is not a near miss, it is a different directory. let inflight: Promise = Promise.resolve(); const refetch = () => { if (!opts.source) { refilter(); return inflight; } const mine = ++generation; inflight = opts .source(query) .catch(() => [] as PickerItem[]) .then((fetched) => { if (mine !== generation) return; // Already the answer to this query, so ranked against nothing rather than filtered twice. visible = fetched.map((item, index) => ({ item, index, positions: [] as number[] })); cursor = Math.min(cursor, Math.max(0, visible.length - 1)); }); return inflight; }; const render = async () => { // A burst of keys — anyone typing at speed, or a paste — arrives as several invocations of the // key command at once, and any of them may be the one that accepts and closes. Whichever runs // next would then be drawing into a window that is gone, which is not an error worth reporting // to the user: it is this widget racing itself. if (closed) return; // Keep the cursor on screen without recentring on every keystroke. if (cursor < top) top = cursor; if (cursor >= top + rows) top = cursor - rows + 1; const lines: string[] = []; if (opts.title) lines.push(opts.title); if (filtering) lines.push(`> ${query}`); // The width the float was asked for *is* the width of its text: a border is drawn outside it, // which is why `columnWidth` subtracts one from each side before opening a panel as wide as the // dock. Measuring against anything else puts the last visible character on both lines — the // continuation says `/ finds` under a row that already ended in `/`. const inner = Math.max(8, width); let window = visible.slice(top, top + rows); // One gutter for the whole list, or none at all. Giving it only to the rows that asked for an // icon would step every other label one column left, which reads as a list that cannot decide // where its left margin is. const gutter = window.some((r) => r.item.icon) ? 2 : 0; const iconFor = (item: PickerItem) => gutter === 0 ? "" : item.icon ? `${item.icon} ` : " "; const textOf = (row: typeof window[number], on: boolean) => `${on ? "❯ " : " "}${iconFor(row.item)}${row.item.label}${row.item.detail ? ` ${row.item.detail}` : ""}`; // The row under the cursor says all of itself. A picker is a list of things you are choosing // between, and two rows whose difference is past the right edge are two rows you cannot choose // between — which is exactly what a long model id, a path, or a description does here. The // rest goes underneath, only while the cursor is on it, so the list is still a list. const on = visible[cursor]; /** The cursor row's first line, which is re-broken at a word when the row has to continue. */ let firstLine = on ? textOf(on, true) : ""; let rest: string[] = []; if (on && columnsOf(firstLine) > inner) { const head = `❯ ${iconFor(on.item)}${on.item.label}`; const detail = on.item.detail ? ` ${on.item.detail}` : ""; // Only the detail is re-broken. The label is what the filter matched and what the match // highlight is measured against, so it stays exactly where it was written; a row whose label // alone overflows falls back to continuing from wherever the edge cut it. if (detail !== "" && columnsOf(head) + 8 < inner) { const [take, remainder] = takeWords(detail, inner - columnsOf(head)); firstLine = `${head}${take}`; rest = remainder.trim() === "" ? [] : wrapToWidth(remainder.trim(), inner - 4); } else { rest = overflowOf(clipToWidth(firstLine, inner), firstLine, inner - 4); } } // The unfolded row pays for its own continuation out of the list's rows rather than out of the // float's height: the window was sized for `height` lines and growing past it would push the // key strip off the bottom, which is the row that says how to get out. if (rest.length > 0) { const room = Math.max(1, rows - rest.length); if (cursor < top) top = cursor; if (cursor >= top + room) top = cursor - room + 1; window = visible.slice(top, top + room); } if (window.length === 0) { lines.push(` ${opts.placeholder ?? "no matches"}`); } /** Which of `lines` each visible row starts on, relative to the first list row. */ const lineFor: number[] = []; const firstListLine = lines.length; for (const row of window) { const isCursor = row.index === visible[cursor]?.index; lineFor.push(lines.length - firstListLine); lines.push(isCursor ? firstLine : textOf(row, false)); if (!isCursor) continue; for (const line of rest) lines.push(` ${line}`); } // Marks are collected against their row and handed over with the text, in one call. Set one at // a time they were a sequence the frontend could draw the middle of: the moment the clear had // landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white. const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] })); const mark = (line: number, col: number, o: MarkOptions) => { drawn[line]?.marks!.push({ col, opts: o }); }; const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0); for (let i = 0; i < window.length; i++) { const row = window[i]!; const line = listTop + (lineFor[i] ?? i); const isCursor = row.index === visible[cursor]?.index; const eol = byteLength(lines[line] ?? ""); if (isCursor) { mark(line, 0, { hlGroup: "Picker.Selected", endCol: eol }); // The band covers the continuation too, so an unfolded row reads as one row that is // several lines tall rather than as a selected row with loose text under it. for (let k = 1; k <= rest.length; k++) { mark(line + k, 0, { hlGroup: "Picker.Selected", endCol: byteLength(lines[line + k] ?? "") }); } } const icon = iconFor(row.item); const marker = byteLength(isCursor ? CURSOR_MARKER : BLANK_MARKER); // The icon is painted before the match runs, and at no priority: a match highlight over the // label is the thing the eye is looking for, and nothing here should be able to outrank it. if (row.item.icon && row.item.hl) { mark(line, marker, { hlGroup: row.item.hl, endCol: marker + byteLength(row.item.icon), }); } const prefix = marker + byteLength(icon); const offsets = byteOffsets(row.item.label); for (const at of row.positions) { const start = offsets[at]; const end = offsets[at + 1]; // A match position past the label came from `keywords`, which is filtered on but not // shown; there is nothing on screen to highlight. if (start === undefined || end === undefined) continue; mark(line, prefix + start, { hlGroup: "Picker.Match", endCol: prefix + end, priority: 200, }); } if (row.item.detail) { mark(line, prefix + byteLength(row.item.label), { hlGroup: "Picker.Detail", endCol: eol, }); } } if (opts.title) { mark(0, 0, { hlGroup: "Float.Title", endCol: byteLength(opts.title) }); } await neosh.buf.render(buf, ns, 0, -1, drawn); // The caret goes where you are typing. Without this the terminal cursor stays parked at the // top-left of the float and the field reads as inert — you type and nothing appears to be // listening, even though the text is right there. With no filter there is nowhere to type, so // it marks the row instead. await neosh.win.setCursor( win, filtering ? (opts.title ? 1 : 0) : listTop + (lineFor[cursor - top] ?? cursor - top), filtering ? byteLength(`> ${query}`) : 0, ); }; let settle: (v: T | null) => void = () => {}; const done = new Promise((resolve) => { settle = resolve; }); const command = `${NS}.key.${++pickerSeq}`; const disposers: Disposable[] = []; let closed = false; const close = async (value: T | null) => { if (closed) return; closed = true; for (const d of disposers) d.dispose(); await neosh.win.close(win).catch(() => {}); settle(value); }; const last = () => Math.max(0, visible.length - 1); /** * Change the filter, and tell whoever is watching. * * One place, because four separate cases used to do it themselves and each one had to remember * the refetch. A completion also has to say so outwards, so the composer it is completing keeps * showing what has been typed. */ const retype = async (next: string) => { query = next; await refetch(); opts.onQuery?.(query); }; if (opts.subscribe) { disposers.push( opts.subscribe(() => { if (closed) return; void (async () => { await refetch(); if (!closed) await render(); })().catch(() => {}); }), ); } disposers.push( await neosh.cmd.register(command, async (_args, key) => { if (!key || closed) return; const before = cursor; // The caller first: a verb it put on a key is a verb about the row under the cursor, and the // widget's own handling of that key would be about the filter. const outcome = await opts.onKey?.(key, { item: visible[cursor]?.item.value, query }); if (closed) return; if (outcome === "close") { await close(null); return; } if (outcome === "reload") { await refetch(); await render(); return; } if (outcome === "handled") { await render(); return; } // Settings next, so a rebound key wins over what the widget would otherwise do with it. const action = actionFor(keys, key.key); switch (action) { case "dismiss": await close(null); return; case "accept": { // Settle whatever is in flight first, so this takes the row for what is actually typed. await inflight; const chosen = visible[cursor]; if (chosen) { await close(chosen.item.value); return; } // Nothing highlighted: a picker that is also a field accepts what you typed. await close(opts.freeform?.(query) ?? null); return; } case "next": cursor = Math.min(last(), cursor + 1); break; case "prev": cursor = Math.max(0, cursor - 1); break; case "page_down": cursor = Math.min(last(), cursor + rows); break; case "page_up": cursor = Math.max(0, cursor - rows); break; case "first": cursor = 0; break; case "last": cursor = last(); break; case "clear": await retype(""); break; case "delete_word": await retype(dropSegment(query)); break; case "complete": { // Take the highlighted row into the field without accepting it — how you walk into a // directory one segment at a time. await inflight; const chosen = visible[cursor]; if (!chosen || !opts.freeform) return; cursor = 0; await retype(chosen.item.label); break; } default: { if (!filtering) return; if (key.key.code.kind === "backspace") { // Backspacing off the end of an empty filter dismisses, so a completion feels like // part of the field rather than a window in front of it. Only where there is something // outside to fall back to — a picker with nowhere to put the keystroke keeps it. if (!query && opts.onQuery) { await close(null); return; } await retype(query.slice(0, -1)); break; } if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return; cursor = 0; await retype(query + key.key.code.c); break; } } await render(); if (cursor !== before && opts.onHighlight) { const row = visible[cursor]; if (row) opts.onHighlight(row.item, row.index); } }, { desc: "picker key" }), ); /** * How many list rows there turned out to be room for. * * A float is *asked* for a height and given whatever the screen has, so `opts.height` is a wish * and this is the answer. Everything above counts in `rows` — where the window starts, what a * page step is, how far the cursor may go before the list scrolls — and while that number was the * wish, a picker on a short terminal scrolled to keep the cursor on a row that was not drawn. * * Read once when the panel opens and again whenever the frontend says this window changed size, * rather than on every keystroke: a round trip per character typed is latency on the one path in * this widget that has to feel immediate. */ async function measure(): Promise { if (closed) return; const v = await neosh.win.viewport(win).catch(() => null); if (!v) return; const next = Math.max(1, v.height - (filtering ? 1 : 0) - (opts.title ? 1 : 0)); if (next === rows) return; rows = next; await render(); } disposers.push( neosh.event.on("neosh.viewport", (e) => { if ((e as { win?: WindowId }).win === win) void measure(); }), ); await neosh.focus.push(win); disposers.push(await neosh.keymap.capture(win, command)); // `` is bound globally to `interrupt`, which would arm "press again to quit" while a picker // is on screen — the one moment the user obviously meant "close this". A window-scoped binding // outranks the global one and is dropped with the window. await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []); // Now, and not before: everything typed while this was being built went somewhere, and if it // went into the field this is completing then it is part of the query. Asked after the keyboard // is ours, so there is no further keystroke to miss. if (typeof opts.query === "function") query = opts.query(); await refetch(); await render(); await measure(); if (opts.onHighlight) { const row = visible[cursor]; if (row) opts.onHighlight(row.item, row.index); } return done; } let pickerSeq = 0; /** * Delete one word, treating a path separator as a boundary. * * `/home/me/src/` becomes `/home/me/`, not `/home/me/src`. Deleting the separator and stopping * would mean pressing the key twice per directory, which is not what anyone means by "back one". */ function dropSegment(text: string): string { const trimmed = text.replace(/[\s/]+$/, ""); const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(" ")); return at < 0 ? "" : trimmed.slice(0, at + 1); } // --------------------------------------------------------------------------- // Reading something too long for the screen // --------------------------------------------------------------------------- export interface PagerOptions { /** On the top border. */ title?: string; /** * On the bottom border, replacing the default legend. * * The default already names the scroll keys and the way out, read out of nothing — they are fixed * defaults on `neosh.scroll` and `^Z` is where a rebinding shows up. Pass your own only to add a * verb of your own to it. */ footer?: string; /** Columns of content. The border is drawn outside it. */ width?: number; /** * The most rows of content to ask for. Fewer if the content is shorter, fewer still if the screen * is — which is the case this whole widget is about, and is why it is a ceiling and not a size. */ height?: number; /** * The buffer kind, so a third party can bind keys in *this* panel and find it with `win.ofKind`. * * Defaults to {@link KIND_PAGER}, which every pager in the workspace shares. Pass your own when * the panel is a thing in its own right — a diff, a status — and somebody might reasonably want * keys on that and not on every pager there is. */ kind?: string; /** Marks to lay over the rows, in the same shape {@link Neosh.buf.render} takes. */ marks?: DrawnRow["marks"][]; z?: number; /** * Handed this panel's own close, as soon as it is on screen. * * For the caller that has to be able to put it away itself — a key that toggles, a panel that * closes when the thing it is about goes. Without it the only way out is a key, and a `^Z` that * opens a sheet and then opens a second one behind it is the bug this exists to prevent. */ onOpen?: (close: () => Promise) => void; } /** The kind a pager's buffer gets when the caller does not name one. */ export const KIND_PAGER = "neosh.pager"; /** * Show rows you can read but not edit, in a float that scrolls when it does not fit. * * The shape almost every read-only panel in the workspace turned out to want, and the thing each of * them was missing: a float is given whatever height the screen has, so a status of sixty changed * files or a diff of four hundred lines was thirty rows on screen and the rest drawn nowhere, with * no key pointed at it and nothing to say it was there. Every one of them had independently written * "open a float, put lines in it" and independently stopped before the part where the terminal is * short. * * What you get: the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys) * and the wheel, because the float sets `scroll` and those are ordinary bindings on * `neosh.scroll` — so `^Z` lists them and `init.ts` moves them. A bar down the right border while * anything is hidden. A legend on the bottom border, which cannot scroll away and is not the first * row clipped. ``, `q`, `` and `^C` close it. * * Resolves when it closes. */ export async function pager( neosh: Neosh, lines: string[], opts: PagerOptions = {}, ): Promise { const buf = await neosh.buf.create({ name: `[${opts.title?.trim() || "pager"}]`, scratch: true, kind: opts.kind ?? KIND_PAGER, }); const ns = await neosh.ns.create("neosh.ui.pager"); await neosh.buf.render( buf, ns, 0, -1, lines.map((text, i) => ({ text, marks: opts.marks?.[i] ?? [] })), ); const win = await neosh.float.open(buf, { anchor: { kind: "screen" }, width: opts.width === undefined ? { kind: "auto" } : { kind: "fixed", n: opts.width }, // A ceiling, never a size. `fixed` here would ask for rows the screen does not have and get // clipped to them anyway — the difference being that `max` also shrinks to fit content that is // shorter, so a two-line status is a two-line panel rather than a mostly empty box. height: { kind: "max", n: opts.height ?? 30 }, border: "rounded", title: opts.title, footer: opts.footer ?? " j k move ^D ^U half gg G ends esc close ", focusable: true, closeOnBlur: true, scroll: true, z: opts.z ?? 200, }); await neosh.focus.push(win); let settle: () => void = () => {}; const done = new Promise((resolve) => { settle = resolve; }); const command = `neosh.ui.pager.key.${++pickerSeq}`; const disposers: Disposable[] = []; let closed = false; const close = async () => { if (closed) return; closed = true; for (const d of disposers) d.dispose(); await neosh.focus.pop().catch(() => {}); await neosh.win.close(win).catch(() => {}); settle(); }; disposers.push( await neosh.cmd.register(command, (_args, key) => { // Only what nothing else claimed reaches here, and the scroll keys are claimed — they resolve // at `neosh.scroll`, which is nearer than this capture is late. So what is left is dismissal. // Which is *named*: a panel you scroll is a panel you spend time in, and "any key closes it" // turns a mistyped letter into losing your place. if (!key || closesPager(key)) void close(); }, { desc: "close this panel" }), ); disposers.push(await neosh.keymap.capture(win, command)); opts.onOpen?.(close); return done; } /** ``, `q`, `` and `^C` — the four keys every panel in this workspace closes on. */ function closesPager(key: KeyContext): boolean { const { code, mods } = key.key; if (code.kind === "esc" || code.kind === "enter") return true; if (code.kind !== "char") return false; return mods.ctrl ? code.c.toLowerCase() === "c" : code.c.toLowerCase() === "q"; } // --------------------------------------------------------------------------- // Derived widgets // --------------------------------------------------------------------------- export interface ConfirmOptions { /** What the affirming answer says. A verb — `Delete`, `Remove`, `Sign out` — never `OK`. */ yes?: string; /** What the answer that changes nothing says. */ no?: string; /** * Under the question, dimmed: what exactly is at stake, and what to do instead. * * The part that makes a dialog worth stopping for. "Are you sure?" is a speed bump you learn to * clear without reading; "4 messages, in neosh · main" and "archiving keeps it" is a question you * can answer without leaving the dialog to go and check. */ detail?: string[]; /** * The affirming answer cannot be undone. * * Starts on the answer that changes nothing — `` is reflex by the second time you have seen a * dialog, and a reflex must not delete anything — and draws the other one in the theme's error * colour, so the row that destroys something does not look like the row beside it. */ dangerous?: boolean; width?: number; } const CONFIRM_NS = "neosh.ui.confirm"; /** * Ask a yes/no question, and mean it. Resolves `false` when dismissed. * * A dialog rather than a two-row picker, because the picker's title is one clipped line and the * question is the whole point: what is about to happen, to what, and what the alternative is. It * wraps, it carries `detail`, and when the answer is destructive it says so in colour rather than * relying on you to read a verb. * * `y` and `n` answer it outright, `↑`/`↓` and `j`/`k` move between the two, `` takes the one * under the cursor, and `` — like every other float in the workspace — means no. */ export async function confirm( neosh: Neosh, question: string, opts: ConfirmOptions = {}, ): Promise { const yes = opts.yes ?? "Yes"; const no = opts.no ?? "No"; const width = Math.min(78, Math.max(36, opts.width ?? 58)); const asked = wrapText(question, width - 2); const detail = (opts.detail ?? []).flatMap((d) => wrapText(d, width - 2)); const answers = [yes, no]; const strip = clipTo( `y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`, width - 2, ); /** * How many of the detail lines there is room for. * * Settled once the frontend has said how tall this dialog turned out to be — a dialog is asked * for a height and given what the screen has, and the rows it loses are the ones at the bottom. * Which for a question with the answers under the detail meant the answers, so a long `detail` on * a short terminal was a dialog you could not see either option in. * * The detail is what gives way, because the detail is the part you can do without: the question * and the two answers are the dialog. What went is said rather than silently dropped. */ let room = detail.length; // On the answer that changes nothing, when the other one cannot be taken back. let cursor = opts.dangerous ? 1 : 0; const buf = await neosh.buf.create({ name: "[confirm]", scratch: true, kind: KIND_CONFIRM }); const ns = await neosh.ns.create(CONFIRM_NS); watchKeys(neosh); const keys = await widgetKeys(neosh); /** Where each part of the dialog starts, so the marks do not have to count rows twice. */ const detailAt = asked.length + 1; /** Every row but the detail: the question, the blanks around it, and the two answers. */ const fixedRows = asked.length + 1 + answers.length; const win = await neosh.float.open(buf, { anchor: { kind: "screen" }, width: { kind: "fixed", n: width }, height: { kind: "fixed", n: fixedRows + (detail.length === 0 ? 0 : detail.length + 1) }, border: "rounded", // The key strip, on the edge. It was the last row of the buffer, which made it both the row a // long `detail` pushed off the bottom and a row of height every dialog had to pay for. footer: ` ${strip} `, focusable: true, closeOnBlur: true, // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`, // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one, // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that // fails to bind a way out is never a terminal somebody has to kill: see // `ui.modal_escape_keys`. modal: true, // Above a picker: this is asked *from* one often enough that being drawn under it would make // the workspace look wedged. z: 260, }); const render = async () => { const shownDetail = room >= detail.length ? detail : // One row of the budget goes to saying what is not being shown, so a dialog that has had to // cut something never looks like a dialog that had nothing more to say. [...detail.slice(0, Math.max(0, room - 1)), `… and ${detail.length - Math.max(0, room - 1)} more lines`]; const detailRows = shownDetail.length; const answersAt = detailAt + (detailRows === 0 ? 0 : detailRows + 1); const lines: string[] = asked.map((l) => ` ${l}`); lines.push(""); if (detailRows > 0) { lines.push(...shownDetail.map((l) => ` ${l}`)); lines.push(""); } answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`)); // Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the // frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also // takes care of the older half of the same bug, that a mark whose line was replaced under it // clamps rather than dies, so rows would end up wearing the colours of the ones before. const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] })); const mark = (line: number, col: number, o: MarkOptions) => { drawn[line]?.marks!.push({ col, opts: o }); }; for (let i = 0; i < asked.length; i++) { mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") }); } for (let i = 0; i < detailRows; i++) { mark(detailAt + i, 0, { hlGroup: "Comment", endCol: byteLength(lines[detailAt + i] ?? ""), }); } for (let i = 0; i < answers.length; i++) { const line = answersAt + i; // The band is the row's *background* rather than a group across its bytes, so the answer keeps // whatever colour said what it was. A ranged group here would leave the destructive row // looking exactly like the safe one for as long as the cursor is on it. if (i === cursor) { mark(line, 0, { lineHlGroup: "Picker.Selected" }); } if (opts.dangerous && i === 0) { mark(line, byteLength(BLANK_MARKER), { hlGroup: "Diagnostic.Error", endCol: byteLength(lines[line] ?? ""), }); } } await neosh.buf.render(buf, ns, 0, -1, drawn); // The caret marks the answer, since there is nothing here to type into. await neosh.win.setCursor(win, answersAt + cursor, 0); }; let settle: (v: boolean) => void = () => {}; const done = new Promise((resolve) => { settle = resolve; }); const command = `${CONFIRM_NS}.key.${++pickerSeq}`; const disposers: Disposable[] = []; let closed = false; const close = async (value: boolean) => { if (closed) return; closed = true; for (const d of disposers) d.dispose(); await neosh.win.close(win).catch(() => {}); settle(value); }; disposers.push( await neosh.cmd.register(command, async (_args, key) => { if (!key || closed) return; switch (actionFor(keys, key.key)) { case "dismiss": await close(false); return; case "accept": await close(cursor === 0); return; case "next": case "last": cursor = answers.length - 1; break; case "prev": case "first": cursor = 0; break; default: { if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return; switch (key.key.code.c.toLowerCase()) { // Answering outright, which is what anyone who has read the question wants. Nothing here // filters, so the letters are free — and `y`/`n` are the two nobody has to be told. case "y": await close(true); return; case "n": case "q": await close(false); return; case "j": cursor = answers.length - 1; break; case "k": cursor = 0; break; default: return; } } } await render(); }, { desc: "confirm key" }), ); await neosh.focus.push(win); disposers.push(await neosh.keymap.capture(win, command)); await bindWidgetKeys(neosh, win, command, keys); await render(); // And then how much of it there was room for. Asked after the first draw, because a window the // frontend has never laid out has no answer to give. const measure = async () => { if (closed) return; const v = await neosh.win.viewport(win).catch(() => null); if (!v) return; const next = Math.max(0, v.height - fixedRows - 1); if (next === room) return; room = next; await render(); }; disposers.push( neosh.event.on("neosh.viewport", (e) => { if ((e as { win?: WindowId }).win === win) void measure(); }), ); await measure(); return done; } /** * Ask before something that cannot be undone — unless the user has said not to. * * One place, so `ui.confirm_destructive` means the same thing everywhere and a plugin does not have * to know the option exists. The bar is *irreversible*, not merely significant: closing a panel, * switching a model or archiving a conversation asks nothing, because you can put those back. A * dialog charged for a reversible action is what teaches people to clear dialogs without reading * them, which is how the one that matters stops working. */ export async function confirmDestructive( neosh: Neosh, question: string, opts: Omit = {}, ): Promise { const ask = (await neosh.opt.get("ui.confirm_destructive").catch(() => true)) ?? true; if (!ask) return true; return confirm(neosh, question, { ...opts, dangerous: true }); } // --------------------------------------------------------------------------- // What goes with a conversation // --------------------------------------------------------------------------- /** * A worktree checkout that a delete is about to leave with nothing in it. * * `dirty` is how many files in it differ from its last commit — the number a dialog has to say, * because `git worktree remove` refuses a tree with changes in it and forcing it is throwing those * changes away. */ export interface OrphanedWorktree { /** The checkout's directory: the conversations' `cwd`. */ path: string; /** The repository it is a tree of, which is where the removal has to run from. */ root: string; branch: string | null; dirty: number; } /** * The worktree at `cwd`, if it is one this workspace may take off the disk. * * Asked of git rather than of the sidebar: a directory is a worktree because `git worktree list` * says so, and the main checkout — the repository itself — is never one, whatever a conversation's * `repo_root` happens to say. Nor is the tree this workspace is running in: git refuses to remove * the checkout it is standing in, and it is right to. */ export async function worktreeAt( neosh: Neosh, cwd: string, root: string, ): Promise { if (root === "" || root === cwd) return null; const trees = await neosh.git.worktrees({ cwd: root }).catch(() => [] as WorktreeInfo[]); const tree = trees.find((t) => t.path === cwd); if (!tree || tree.is_main || tree.is_current) return null; const status = await neosh.git.status({ cwd }).catch(() => null); return { path: cwd, root, branch: tree.branch ?? null, dirty: status?.changes.length ?? 0 }; } /** * The worktrees that would be left with no conversation in them if `ids` were deleted. * * A worktree exists for the conversations in it — that is what `^N` makes one for — so the last of * them going is the checkout's reason for being on the disk going with it. Counted over every * conversation the workspace knows of, archived and on-disk-only included: a tree with an archived * conversation still in it is a tree somebody may come back to. */ export async function orphanedWorktrees( neosh: Neosh, ids: Iterable, ): Promise { const doomed = new Set(ids); if (doomed.size === 0) return []; const [live, disk] = await Promise.all([ neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]), neosh.session.stored().catch(() => [] as SessionInfo[]), ]); const all = new Map(); for (const s of disk) all.set(s.id, s); for (const s of live) all.set(s.id, s); // Which checkouts the deletion empties: every conversation in them is going. const roots = new Map(); for (const s of all.values()) { if (!s.repo_root || s.repo_root === s.cwd) continue; if (doomed.has(s.id)) roots.set(s.cwd, s.repo_root); } for (const s of all.values()) { if (!doomed.has(s.id)) roots.delete(s.cwd); } const found = await Promise.all( [...roots].map(([cwd, root]) => worktreeAt(neosh, cwd, root)), ); return found.filter((t): t is OrphanedWorktree => t !== null); } /** * What a delete dialog says about the checkouts it takes with it. * * Lines for {@link ConfirmOptions.detail}: which directory goes, that the branch stays, and — the * part that must never be a surprise — how many uncommitted changes go with it. */ export function worktreeLines(trees: OrphanedWorktree[]): string[] { if (trees.length === 0) return []; const lines: string[] = []; if (trees.length === 1) { const t = trees[0]!; lines.push( `Nothing else is in the worktree at ${t.path}, so the checkout goes too${ t.branch ? `; the branch ${t.branch} stays` : "" }.`, ); } else { lines.push( `${trees.length} worktrees are left with nothing in them and go from disk too; their branches stay.`, ); } const dirty = trees.reduce((n, t) => n + t.dirty, 0); if (dirty > 0) { lines.push( trees.length === 1 ? `It has ${dirty} uncommitted ${dirty === 1 ? "change" : "changes"}, which go with it.` : `${dirty} uncommitted ${dirty === 1 ? "change" : "changes"} across them go with them.`, ); } return lines; } /** * Take the checkouts off the disk, after the dialog that named them has been answered. * * Through the git plugin's `git.worktree.discard` rather than the API directly, because a write * to a repository is that plugin's verb — it holds the permission, it runs the removal from the * repository rather than from the tree, and it drops the panel's row for the directory. Forced * there, because the dialog already said what was in the tree, and git refusing now would be the * same question asked twice. A tree that would not go is reported by path and left, with its * conversations already gone; `^O` on the directory brings it back as a project. Answers with how * many went. */ export async function discardWorktrees( neosh: Neosh, trees: OrphanedWorktree[], ): Promise { let removed = 0; for (const t of trees) { try { await neosh.cmd.exec("git.worktree.discard", [t.path, t.root]); removed += 1; } catch (e) { neosh.notify(`could not remove the worktree at ${t.path}: ${String(e)}`, "warn"); } } return removed; } /** * Break text into lines of at most `width` characters. * * Counted in characters and not columns: measuring display width is the frontend's job, and this * feeds a float that is sized with a column to spare. A word longer than the line is left long and * clipped where it is drawn, by the one thing that knows where its own edge is. */ function wrapText(text: string, width: number): string[] { const out: string[] = []; for (const paragraph of text.split("\n")) { let line = ""; for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) { const next = line === "" ? word : `${line} ${word}`; if (line !== "" && Array.from(next).length > width) { out.push(line); line = word; } else { line = next; } } out.push(line); } return out; } function clipTo(text: string, width: number): string { const chars = Array.from(text); return chars.length <= width ? text : `${chars.slice(0, Math.max(1, width - 1)).join("")}…`; } // --------------------------------------------------------------------------- // Showing the rest of a row // --------------------------------------------------------------------------- /** * Break `text` into lines of at most `columns` **display columns**, splitting words that are wider * than the column rather than letting them overflow it. * * The difference from {@link wrapText} is the whole reason both exist. That one counts code points * and leaves a long word long, because it feeds a float that is sized with a column to spare and * the frontend clips the overhang. This one feeds a *column of a row* — a description beside a * label, a continuation under a title — where an overhanging word does not get clipped, it pushes * whatever is to its right off the edge and takes the alignment of every row below it with it. A * URL, a path or a `--flag=value` is routinely wider than the column it lands in, so this is the * common case and not the exotic one. * * Measured with {@link width}, which is the op the renderer itself uses — so a CJK label and an * ASCII one wrap at the same visual place. */ export function wrapToWidth(text: string, columns: number): string[] { const limit = Math.max(1, Math.floor(columns)); const out: string[] = []; for (const paragraph of text.split("\n")) { let line = ""; for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) { const next = line === "" ? word : `${line} ${word}`; if (width(next) <= limit) { line = next; continue; } if (line !== "") { out.push(line); line = ""; } // A word that cannot fit a line of its own is cut across as many as it needs. Cutting is // right here and wrong in prose: this is an identifier, not a sentence, and the alternative // is not a nicer break but a row that is silently wider than its column. let rest = word; while (width(rest) > limit) { const head = clipToWidth(rest, limit); if (head === "") break; out.push(head); rest = rest.slice(head.length); } line = rest; } out.push(line); } return out.length > 0 ? out : [""]; } /** * The part of `full` that `shown` did not get to say, as lines of `columns` columns. * * `shown` is the row as it was actually written — clipped, and ending in an ellipsis if it was. * The ellipsis is dropped before comparing, so what comes back starts exactly where the visible * text stopped, and the two read as one sentence broken across a line. * * Empty when nothing was lost, which is the common case and has to cost nothing: a list of short * rows must not grow a blank line under the cursor. */ export function overflowOf(shown: string, full: string, columns: number): string[] { if (full === "" || columns < 1) return []; // Everything before the ellipsis is what actually reached the screen. What comes *after* it is // decoration that survived the clip — an unread dot, a count, a state glyph pinned to the end of // the row — and it is not part of the text, so it is neither compared against `full` nor said // again underneath. const cut = shown.lastIndexOf("…"); const visible = cut >= 0 ? shown.slice(0, cut) : shown; // Nothing was lost. The common case, and it has to cost nothing: a list of short rows must not // grow a blank line under the cursor. if (visible.startsWith(full)) return []; // A `full` that is not a continuation of what is on screen means the caller built the two // differently rather than by clipping one from the other. The whole of it is then the honest // thing to show, because there is no prefix to trust. const rest = full.startsWith(visible) ? full.slice(visible.length) : full; if (rest.trim() === "") return []; return wrapToWidth(rest.replace(/^\s+/, ""), columns); } /** * As much of `text` as fits `columns` **without breaking a word**, and the rest of it. * * What a row's first line needs. {@link overflowOf} continues from wherever the clip landed, which * is right when the clip is somebody else's — the row is already drawn and ends in an ellipsis that * says so. When the row is *ours* to lay out, breaking `work` into `wor` and `k` is a choice, and a * bad one: the eye stops at the ragged edge and the reader spends a beat rejoining a word instead of * reading the sentence. * * A single word wider than the column is cut anyway. There is nothing else to do with it, and the * alternative — a first line left empty — is worse. */ export function takeWords(text: string, columns: number): [string, string] { const limit = Math.max(1, Math.floor(columns)); if (width(text) <= limit) return [text, ""]; // Split keeping the separators, so what is left over is the exact remainder of the original // rather than a rejoin of it: two spaces after a full stop stay two spaces. const parts = text.split(/(\s+)/); let head = ""; for (const part of parts) { const next = head + part; if (width(next.trimEnd()) > limit) break; head = next; } if (head.trim() === "") head = clipToWidth(text, limit); return [head.trimEnd(), text.slice(head.length)]; } /** * {@link width}, under a name nothing shadows. * * `picker` binds `width` to its own float's column count, which is a number — so calling the * measuring function inside it is a type error at best and a silent wrong answer at worst. */ const columnsOf = width; /** How many columns of leading space `text` has. */ function leading(text: string): number { return text.length - text.replace(/^ +/, "").length; } /** * Type a directory path, with completion as you go. * * The list under the field is the directories that match what you have typed so far, refreshed on * every keystroke. `` takes the highlighted one into the field so you can keep going, the * movement keys pick a different one, and `` accepts either the highlighted row or exactly what * you typed — because the directory you want may not be one this offers. * * `` deletes a whole segment rather than a character, which is the difference between walking * back up a tree and holding backspace. * * Resolves to the path, or `null` if dismissed. */ export async function pathPicker( neosh: Neosh, title: string, opts: { initial?: string; width?: number; height?: number } = {}, ): Promise { // The last refusal said out loud, so a folder the OS is hiding is reported once rather than on // every keystroke into it. `source` runs per character typed, and a notice per character is the // same message forty times for one cause. let saidDenied: string | null = null; return picker(neosh, [], { title, width: Math.max(40, opts.width ?? 72), height: Math.max(4, opts.height ?? 10), query: opts.initial ?? "", placeholder: "no directory matches — takes what you typed", source: async (query) => { const answer = await neosh.path .complete(query) .catch(() => ({ paths: [] as string[], denied: undefined })); // An empty list because nobody was allowed to look is not an empty directory, and the // placeholder under this field can only say the second one. Said the way the remote branch // of the same question says it — a notice, because it is feedback for a key you pressed. if (answer.denied && answer.denied !== saidDenied) { saidDenied = answer.denied; neosh.notify(answer.denied, "warn"); } else if (!answer.denied) { saidDenied = null; } return answer.paths.map((path) => ({ label: path, value: path })); }, // What you typed, when it is not one of the offered rows. A completion list that refuses a path // it did not think of is a list that gets in the way. freeform: (query) => (query.trim() === "" ? null : query.trim()), }); } /** * A single-line text field. Resolves to the text, or `null` if dismissed. * * Built on the same capture the picker uses, so it behaves the same way: bindings still win, and * `` gets you out. */ export async function prompt( neosh: Neosh, title: string, opts: { initial?: string; width?: number } = {}, ): Promise { const width = Math.max(24, opts.width ?? 60); const buf = await neosh.buf.create({ name: "[prompt]", scratch: true, kind: KIND_PROMPT }); const win = await neosh.float.open(buf, { anchor: { kind: "screen" }, width: { kind: "fixed", n: width }, height: { kind: "fixed", n: 2 }, border: "rounded", // A field with no legend on it is a field you guess at. On the border, so it costs neither of // the two rows this has. footer: " ↵ accept ^W a word ^U all esc cancel ", focusable: true, closeOnBlur: true, // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`, // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one, // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that // fails to bind a way out is never a terminal somebody has to kill: see // `ui.modal_escape_keys`. modal: true, z: 200, }); watchKeys(neosh); const keys = await widgetKeys(neosh); let text = opts.initial ?? ""; const render = async () => { await neosh.buf.setLines(buf, 0, -1, [title, `> ${text}`]); // The caret belongs where the typing goes. Left at the origin it sits on the title, and the // field reads as inert no matter what appears in it. await neosh.win.setCursor(win, 1, byteLength(`> ${text}`)); }; let settle: (v: string | null) => void = () => {}; const done = new Promise((resolve) => { settle = resolve; }); const command = `neosh.ui.prompt.key.${++pickerSeq}`; const disposers: Disposable[] = []; let closed = false; const close = async (value: string | null) => { if (closed) return; closed = true; for (const d of disposers) d.dispose(); await neosh.win.close(win).catch(() => {}); settle(value); }; disposers.push( await neosh.cmd.register(command, async (_args, key: KeyContext | undefined) => { if (!key) return; switch (actionFor(keys, key.key)) { case "dismiss": await close(null); return; case "accept": await close(text); return; case "clear": text = ""; break; case "delete_word": text = dropSegment(text); break; default: { if (key.key.code.kind === "backspace") { text = text.slice(0, -1); break; } if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return; text += key.key.code.c; break; } } await render(); }, { desc: "prompt key" }), ); await neosh.focus.push(win); disposers.push(await neosh.keymap.capture(win, command)); await bindWidgetKeys(neosh, win, command, keys); await render(); return done; } /** * Route every widget key to the widget's own handler while its window is focused. * * A raw capture only receives what *no binding claimed*. That used to be the whole story, and it * meant `` never reached a picker: `` is bound globally to "new conversation", so you * would be typing a filter and suddenly find yourself in a new conversation with the picker gone. * * Window-scoped bindings outrank global ones and are dropped with the window, so a widget owns its * keys for exactly as long as it is on screen and not one keystroke longer. Modes are enumerated * because a binding is per mode and a picker can be opened from any of them. * * These floats are also `modal`, which is the other half and the half this could not be: a widget * can name the keys it *wants* and never the ones it merely does not want to happen. Both are * needed — modality stops `` opening a panel behind the picker, and these bindings are what * point `` at "next" rather than at nothing. */ async function bindWidgetKeys( neosh: Neosh, win: WindowId, command: string, keys: Map, extra: readonly string[] = [], ): Promise { const lhs = new Set(extra); for (const specs of keys.values()) { for (const spec of specs) lhs.add(spec.lhs); } for (const mode of ["normal", "insert", "visual", "chat"] as const) { for (const key of lhs) { // A key that will not parse on the host side is not worth failing the whole widget over; the // capture still delivers it if nothing else claimed it. await neosh.keymap.set(mode, key, command, { scope: { kind: "window", win } }).catch(() => {}); } } } /** * Highlight groups the widgets here use, linked to ones a theme already defines. * * Call once from your plugin's `activate` if you want them; they are links, not colors, so an * unknown theme still renders something sensible. */ export async function defineHighlights(_neosh: Neosh): Promise { // Nothing to define any more: the groups below are part of the core palette, so they resolve in // every theme and follow a theme switch without this library hearing about it. Kept as a no-op // because plugins call it, and because a future widget may need a group of its own. } /** Re-exported so a caller can type a picker's list without importing from two places. */ export type { BufferId, WindowId }; // --------------------------------------------------------------------------- // Version-control display // --------------------------------------------------------------------------- /** * The single letter git itself uses for a state. * * Written out rather than derived from the enum name, because two of them do not agree with their * spelling: untracked is `?`, not `U`, and `U` means *unmerged*. A sidebar that labels every new * file `U` is telling the user they have a merge conflict. */ export function stateLetter(state: FileState | null | undefined): string { switch (state) { case "added": return "A"; case "modified": return "M"; case "deleted": return "D"; case "renamed": return "R"; case "copied": return "C"; case "type_changed": return "T"; case "conflicted": return "U"; case "untracked": return "?"; case "ignored": return "!"; default: return " "; } } /** The two-column `XY` prefix git status prints: staged state, then working-tree state. */ export function statusPrefix(change: FileChange): string { return `${stateLetter(change.staged)}${stateLetter(change.unstaged)}`; } /** * Fit a path into `width` display columns, keeping the end. * * The tail is what identifies a file; the head is what repeats. Measured in columns rather than * characters, so a path with CJK in it does not overflow a narrow panel. * * Git reports untracked *directories* with a trailing slash — `crates/` — which `basename` turns * into an empty string. Kept here so every caller gets that right. */ export function shortenPath(path: string, width: number): string { const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; const suffix = path.endsWith("/") ? "/" : ""; const chars = Array.from(trimmed); const budget = Math.max(4, width - suffix.length); if (chars.length <= budget) return trimmed + suffix; return `\u2026${chars.slice(-(budget - 1)).join("")}${suffix}`; } // --------------------------------------------------------------------------- // Motion // --------------------------------------------------------------------------- /** * Ambient motion, on one shared clock. * * The rule this follows is not a terminal compromise — it is the same rule the web app states * outright: *no continuously repainting animations*. Its four animations are duty-cycled with * `steps()` so a 2-second pulse is a dozen discrete frames rather than 240. A terminal is that * model already: discrete cells, discrete frames, and a diffing renderer that writes only what * changed. * * So: **one clock**, module-global and therefore shared by every plugin, ticking at * {@link TICK_MS}. Every spinner in the session is on the same frame and every pulse toggles in the * same repaint, rather than each row owning a timer and smearing writes across the second. * * Measured cost of the whole system in a real terminal: ~1.3% of one core and 0.6 KiB/s at 80 ms, * because the renderer sends only changed cells. That holds over SSH. */ /** One frame of the shared clock. 100 ms × 10 braille frames is exactly the web's 1000 ms spin. */ export const TICK_MS = 100; /** The spinner the web app uses 28 times over, as braille. */ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /** For a terminal that cannot draw braille. Four frames at 250 ms is the same 1000 ms cycle. */ const ASCII = ["|", "/", "-", "\\"]; let ticks = 0; let handle: number | null = null; const listeners = new Set<() => void>(); let ascii = false; let enabled = true; /** * Subscribe to the shared clock. * * The clock starts on the first subscriber and stops on the last, so an idle session has no timer * at all — and unloading a plugin, which disposes its subscriptions, takes its share with it. * * Push the result into `ctx.subscriptions`. A subscriber that is never disposed keeps the clock * running for the life of the session. */ export function onTick(cb: () => void): Disposable { listeners.add(cb); if (handle === null) { handle = setInterval(() => { ticks++; for (const l of listeners) { try { l(); } catch { // One misbehaving subscriber must not stop every other spinner in the session. } } }, TICK_MS) as unknown as number; } return { dispose() { listeners.delete(cb); if (listeners.size === 0 && handle !== null) { clearInterval(handle); handle = null; } }, }; } /** * The current spinner frame. * * Read it, do not own it: every caller reading the same function on the same clock is what keeps * two spinners on screen from wobbling against each other. */ export function spinnerFrame(): string { if (!enabled) return ascii ? "*" : "•"; const frames = ascii ? ASCII : BRAILLE; const divisor = ascii ? Math.round(250 / TICK_MS) : 1; return frames[Math.floor(ticks / divisor) % frames.length] ?? frames[0]!; } /** * The 1 Hz duty cycle behind every pulsing dot: one second bright, one second dim. * * Two states, never a ramp — the web's `status-pulse` quantizes to exactly two opacities and this * is the same thing with the `dim` attribute. Returning a boolean rather than a glyph keeps the * cell width and the accessible text stable while only the attribute changes. */ export function pulseBright(): boolean { if (!enabled) return true; return Math.floor(ticks / Math.round(1000 / TICK_MS)) % 2 === 0; } /** Highlight group for a pulsing indicator, for use as `hlGroup`. */ export function pulseHl(bright: string, dim = "Comment"): string { return pulseBright() ? bright : dim; } /** * Configure motion for this session. * * Called once by whoever owns the setting — the bundled plugins read `ui.motion` and * `ui.ascii_only`. With motion off, `spinnerFrame` returns a single static glyph and `pulseBright` * is always true, so callers need no branch of their own. */ export function configureMotion(opts: { enabled?: boolean; ascii?: boolean }): void { if (opts.enabled !== undefined) enabled = opts.enabled; if (opts.ascii !== undefined) ascii = opts.ascii; } export function motionEnabled(): boolean { return enabled; } /** Elapsed time as the web app words it: `12s`, `1m 04s`, `1h 02m`. */ export function elapsed(ms: number): string { const total = Math.max(0, Math.floor(ms / 1000)); if (total < 60) return `${total}s`; const minutes = Math.floor(total / 60); const seconds = total % 60; if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; const hours = Math.floor(minutes / 60); return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; } /** * A horizontal meter, as cells. * * Used for the context-window gauge and for anything with a fraction. Repaint only when the filled * count changes: a meter that rewrites itself every tick to draw the same thing is the exact cost * this module exists to avoid. */ export function meter(fraction: number, width: number, opts: { ascii?: boolean } = {}): string { const w = Math.max(1, width); const clamped = Math.min(1, Math.max(0, Number.isFinite(fraction) ? fraction : 0)); const filled = Math.round(clamped * w); const [on, off] = opts.ascii ?? ascii ? ["#", "-"] : ["█", "░"]; return on!.repeat(filled) + off!.repeat(w - filled); } /** `1.2k`, `45.3k`, `1.8M` — the compact forms a status line has room for. */ export function compact(n: number): string { const v = Math.max(0, Math.round(n)); if (v < 1000) return String(v); if (v < 1_000_000) { const k = v / 1000; return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`; } const m = v / 1_000_000; return `${m < 10 ? m.toFixed(1) : Math.round(m)}M`; } /** `$0.0042`, `$1.23`, `$12.30` — enough precision to be useful at both ends. */ export function money(usd: number): string { if (!Number.isFinite(usd) || usd <= 0) return "$0"; if (usd < 0.01) return `$${usd.toFixed(4)}`; if (usd < 1) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; } // --------------------------------------------------------------------------- // Cursored list // --------------------------------------------------------------------------- /** * A row in a {@link CursoredList}. * * `right` is rendered flush against the pane's right edge by the frontend, which is the only thing * that knows how wide the pane is or how many columns a character occupies. Building that alignment * here would mean measuring display width in a plugin, which is exactly what the byte-offset * protocol exists to prevent. */ export interface ListRow { text: string; /** * The whole of what this row says, when `text` is a clipped version of it. * * A panel is a column of fixed width and a conversation title is not, so rows get cut — and a row * cut at the edge is a row you cannot read, which in a list of conversations means you cannot * tell two of them apart. Set this to the row as it *would* have been written with unlimited * room, and the list shows the rest of it on continuation lines whenever the cursor is on that * row, folding back to one line the moment the cursor leaves. * * The cursor is what asks. Only one row can be under it, so only one row is ever more than a line * tall, and the column stays scannable — which is the thing a list is for and the thing that * wrapping everything all the time takes away. * * Include the same prefix `text` has: the marker, the glyph, the indent. What comes back is the * difference between the two, so `text: " ▸ some long ti…"` with `full: " ▸ some long title"` * continues with `tle` and not with the glyph again. Leave out anything the clip *kept* — a * trailing unread dot, a count — since that is already on screen and saying it twice under the * row is how a marker stops meaning anything. Nothing happens without * {@link CursoredListOptions.width}, because nothing here can measure a column it was not told * about. */ full?: string; /** * Keep this row unfolded whether or not the cursor is on it. * * The cursor asking is the rule, and this is the one exception worth having: the conversation * you are *in* is not a row you are considering, it is where you are, and a panel that abbreviates * it to `Fix the login re…` until you happen to move the cursor onto it is abbreviating the one * row you already know you want. Needs {@link ListRow.full} and * {@link CursoredListOptions.width}, like every other unfolding here. Use it for a row that is * *current*, never for one that is merely long — every row that opts in is a row the column can * no longer be scanned down. */ expand?: boolean; /** * The column continuation lines line up under. Defaults to the row's own indent plus two. * * Set it where the default would be wrong — a row whose text starts with a marker and a glyph * has its *content* several columns in from its indent, and continuations that ignore that read * as a second row rather than as the rest of this one. */ indent?: number; /** Highlight group for the whole row. Link to one the theme defines. */ hl?: string; /** * Highlights for pieces of the row, over the top of `hl`: a favourite marker, a state glyph, a * matched substring. `from`/`to` are UTF-8 byte offsets into `text` — the same unit every column * on the wire uses — so build them with {@link byteLength} rather than `.length`. */ spans?: Array<{ from: number; to: number; hl: string }>; /** Flush-right status: a timestamp, a count, a state word. */ right?: { text: string; hl?: string }; /** Rows that cannot be landed on: headings, separators, blanks. */ inert?: boolean; value?: T; } export interface CursoredListOptions { /** Highlight for the row the cursor is on. Defaults to the theme's `Sidebar.Selected`. */ cursorHl?: string; /** Called after every move, with the row landed on. */ onMove?(index: number): void; /** * How many columns the panel has, asked at render time. * * Asked rather than passed once, because a panel is resized and a width captured at construction * is a width that goes stale on the first drag. Without it {@link ListRow.full} does nothing: * a plugin cannot measure the dock it was given, and guessing is how a continuation line ends up * wrapping one column past the edge for the rest of the session. */ width?(): number; } /** * The list behaviour every panel in a workspace needs, in one place. * * Owns a cursor that skips inert rows, renders text and marks together, and keeps the cursor on * screen. It does **not** own a window, a buffer or any keys — a docked sidebar and a floating * picker want different chrome and different bindings, and the part worth sharing is the part * below. */ export class CursoredList { private rows: ListRow[] = []; private cursor = 0; /** Where the last render put the cursor's row: its buffer line, and the window's top line. */ private placed: { line: number; top: number } | null = null; constructor( private readonly neosh: Neosh, private readonly buf: BufferId, private readonly ns: number, private readonly opts: CursoredListOptions = {}, ) {} get index(): number { return this.cursor; } get current(): ListRow | undefined { return this.rows[this.cursor]; } get value(): T | undefined { return this.rows[this.cursor]?.value; } /** Every row's value, top to bottom, skipping rows that have none. What a panel publishes. */ get values(): T[] { return this.rows.flatMap((r) => (r.value === undefined ? [] : [r.value])); } get length(): number { return this.rows.length; } /** * The window row the cursor's row was drawn on, as of the last `render` that had a window to * measure — `null` before one has. * * For a panel beside this one that wants to sit level with the row under the cursor. Counted in * drawn lines rather than in rows, because an unfolded row above the cursor is several, and it * is read back from the scroll `render` settled on rather than from the viewport it was handed, * which was where the window *had* been. */ get cursorScreenRow(): number | null { return this.placed === null ? null : Math.max(0, this.placed.line - this.placed.top); } /** * Replace the contents, keeping the cursor where it makes sense. * * Anchored to `value` identity rather than index: a list that reorders under a running turn would * otherwise move the selection out from under the user's next keystroke. */ setRows(rows: ListRow[], sameAs?: (a: T, b: T) => boolean): void { const previous = this.rows[this.cursor]?.value; this.rows = rows; if (previous !== undefined && sameAs) { const found = rows.findIndex((r) => r.value !== undefined && sameAs(r.value, previous)); if (found >= 0) { this.cursor = found; return; } } this.clamp(); } private clamp(): void { if (this.rows.length === 0) { this.cursor = 0; return; } this.cursor = Math.min(Math.max(0, this.cursor), this.rows.length - 1); if (this.rows[this.cursor]?.inert) { const after = this.rows.findIndex((r, i) => i >= this.cursor && !r.inert); const anywhere = this.rows.findIndex((r) => !r.inert); this.cursor = after >= 0 ? after : Math.max(0, anywhere); } } /** * Move by `delta` selectable rows. * * Wraps by default, because a list you cannot get back to the top of is a chore. A page step * passes `wrap: false`: `^D` at the foot of the list means "there is no more", and one that * silently reappears at the top is a keypress that loses your place in a column you were * reading downwards. * * `delta` counts rows you can land on, not buffer rows — headings, rules and blanks are not * places, so `5j` past two separators moves five conversations rather than three. */ move(delta: number, opts: { wrap?: boolean } = {}): void { if (this.rows.length === 0 || delta === 0) return; const wrap = opts.wrap ?? true; const step = delta < 0 ? -1 : 1; let remaining = Math.abs(delta); let i = this.cursor; let landed = -1; // Bounded by the whole list per row asked for: an all-inert list has nowhere to go, and a // wrapping search for a row that does not exist would otherwise spin. const limit = this.rows.length * Math.abs(delta) + this.rows.length; for (let n = 0; n < limit && remaining > 0; n++) { let next = i + step; if (next < 0 || next >= this.rows.length) { if (!wrap) break; next = (next + this.rows.length) % this.rows.length; } i = next; if (!this.rows[i]?.inert) { landed = i; remaining--; } } if (landed < 0) return; this.cursor = landed; this.opts.onMove?.(landed); } /** * The first or last row you can land on — `gg` and `G`. * * A separate verb from a large `move`, because "the end" is a place and "a hundred rows down" * is a guess about where the end is. */ toEnd(which: "first" | "last"): void { const found = which === "first" ? this.rows.findIndex((r) => !r.inert) : this.rows.reduce((acc, r, i) => (r.inert ? acc : i), -1); if (found < 0) return; this.cursor = found; this.opts.onMove?.(found); } /** The n-th row you can land on, 1-based, the way `5G` counts. */ nth(n: number): void { let seen = 0; for (const [i, row] of this.rows.entries()) { if (row.inert) continue; seen += 1; if (seen === n) { this.cursor = i; this.opts.onMove?.(i); return; } } this.toEnd("last"); } /** Put the cursor on the first row whose value matches. */ select(match: (value: T) => boolean): boolean { const found = this.rows.findIndex((r) => r.value !== undefined && match(r.value)); if (found < 0) return false; this.cursor = found; return true; } /** * Write the rows and their marks. * * `showCursor` is false when the panel does not have the keyboard: a cursor drawn on an unfocused * list claims an attention it does not have, and two visible cursors is worse than none. * * `pinned` is how many rows at the end sit against the *bottom edge* rather than after the * content: a gauge or a key strip is a foot, and a foot that floats halfway up a half-empty * column is chrome that moves every time the list above it grows by one. It costs blank lines * and nothing else — when the list is longer than the window there is no room to pin anything * to, and the rows go back to being the end of the list, which is where scrolling will find * them. */ async render( opts: { showCursor?: boolean; win?: WindowId; pinned?: number } = {}, ): Promise { const cursorHl = this.opts.cursorHl ?? "Sidebar.Selected"; const columns = this.opts.width?.() ?? 0; const drawn: DrawnRow[] = []; /** Which buffer line each row starts on. Not the row's index once anything has unfolded. */ const lineOf: number[] = []; /** How tall the cursor's row turned out, so scrolling can keep all of it on screen. */ let block = 1; this.rows.forEach((row, i) => { lineOf[i] = drawn.length; const eol = byteLength(row.text); const onCursor = opts.showCursor !== false && i === this.cursor; const marks: DrawnMark[] = []; // The cursor is the row's **band**, not a run over its text — which is the difference // between a selection and a highlight, and the workspace already has a vocabulary for it: // `lineHlGroup` is the thing under everything else on the row, and every ranged group is // patched over it. As a ranged mark at priority 200 it instead *won* every character it // covered, which is right for a colour and wrong for two things it was also swallowing. // // The one that made it a bug rather than a preference is **motion**. A group that animates // animates because it won its run — so a project fetching, whose badge is one cell of // `Git.Fetching`, stopped spinning the moment you put the cursor on it and started again // when you moved off. That is exactly backwards: the row you are standing on is the row you // are asking about, and a spinner is the only thing on it that says the answer is not in // yet. The other is that a git badge, a favourite's star and the marker on a remote row all // went one flat colour under the cursor, which is the row least able to spare them. if (onCursor && eol > 0) { marks.push({ col: 0, opts: { lineHlGroup: cursorHl, priority: 200 } }); } if (row.hl && eol > 0) { marks.push({ col: 0, opts: { hlGroup: row.hl, endCol: eol } }); } // Above the row's own highlight: a marker keeps its colour on an ordinary row. Nothing to // rank against the cursor any more — it is underneath all of this now. for (const s of row.spans ?? []) { if (s.to <= s.from || s.from >= eol) continue; marks.push({ col: s.from, opts: { hlGroup: s.hl, endCol: Math.min(s.to, eol), priority: 100 }, }); } if (row.right) { marks.push({ col: 0, opts: { virtText: [{ text: row.right.text, hlGroup: row.right.hl ?? "Comment" }], virtTextPos: "right", }, }); } drawn.push({ text: row.text, marks }); // The rest of a row that did not fit: under the cursor, or on a row that asked to stay open. if ((!onCursor && !row.expand) || row.full === undefined || columns < 1) return; const indent = Math.max(0, Math.min(row.indent ?? leading(row.text) + 2, columns - 8)); const rest = overflowOf(row.text, row.full, columns - indent); // Only the cursor's block is scrolled to. A row that is permanently open is not somewhere // the cursor is, so counting its height here would scroll the panel to a row nobody moved to. if (onCursor) block = 1 + rest.length; for (const line of rest) { const text = `${" ".repeat(indent)}${line}`; const end = byteLength(text); const cont: DrawnMark[] = []; // The band runs the whole block. A continuation drawn on the terminal background reads as // a separate row that happens to be indented, which is the one thing it must not be. Only // where there is a band to run: an always-open row that is not under the cursor gets its // own colour and nothing else, or every one of them would look selected. if (onCursor) cont.push({ col: 0, opts: { lineHlGroup: cursorHl, priority: 200 } }); if (row.hl) cont.push({ col: 0, opts: { hlGroup: row.hl, endCol: end } }); drawn.push({ text, marks: cont }); } }); // The foot, against the bottom edge. Measured after everything is drawn rather than counted in // rows, because an unfolded row is several lines tall and padding by row count would leave the // strip one line short for every row that happened to be open. const pinned = Math.min(Math.max(0, opts.pinned ?? 0), this.rows.length); // One round trip, used twice — and skipped when neither the foot nor the cursor needs it, since // this runs on a tick while a turn is in flight and an unfocused panel that pins nothing has // nothing to measure. const needed = pinned > 0 || opts.showCursor !== false; const view = opts.win === undefined || !needed ? null : await this.neosh.win.viewport(opts.win).catch(() => null); if (pinned > 0) { const at = lineOf[this.rows.length - pinned] ?? drawn.length; const filler = (view?.height ?? 0) - drawn.length; if (filler > 0) { drawn.splice(at, 0, ...Array.from({ length: filler }, () => ({ text: "", marks: [] }))); for (let i = this.rows.length - pinned; i < this.rows.length; i++) { lineOf[i] = (lineOf[i] ?? 0) + filler; } } } // One call, not one per mark. A panel that wrote its text, cleared its namespace and then set // its marks a call at a time was observable halfway through — and a frame landing after the // clear drew every row unmarked, which is `Normal`, which is near-white. That is what a sidebar // full of running agents was flashing. await this.neosh.buf.render(this.buf, this.ns, 0, -1, drawn); // Keep the cursor on screen without recentring on every keystroke. In buffer lines, not row // indices: an unfolded row is several lines tall and the two stopped agreeing the moment one // was — which would scroll to the wrong place by however many rows above it had ever unfolded. if (opts.win !== undefined && opts.showCursor !== false) { const v = view; if (v && v.height > 0) { const at = lineOf[this.cursor] ?? this.cursor; // The whole of the row, not only the line it starts on: scrolling until the *first* line // is visible leaves the continuation you unfolded it for below the bottom edge. const end = at + Math.min(block, v.height) - 1; let top = v.top_line; if (at < top) top = at; else if (end >= top + v.height) top = end - v.height + 1; if (top !== v.top_line) await this.neosh.win.scrollTo(opts.win, top); this.placed = { line: at, top }; } } } } // --------------------------------------------------------------------------- // Two-pane picker // --------------------------------------------------------------------------- /** * One entry in the left rail: a provider, an account, a category. * * The mark is drawn in its own highlight group, which is how a rail of eleven providers stays * legible — you find the one you want by shape and colour before you have read a word. */ export interface RailItem { /** A brand mark, one or two columns. Drawn in `mark.hl`. */ mark?: { text: string; hl?: string }; label: string; /** A state marker at the right edge — signed in, needs a key, unavailable. */ badge?: { text: string; hl?: string }; /** Heading this sits under. Groups appear in the order their first member does. */ group?: string; /** Listed, dimmed, and skipped by the cursor. For a provider whose driver is missing. */ disabled?: boolean; value: G; } /** One row of the right-hand list. */ export interface PaneItem extends PickerItem { /** A middle column, aligned across rows: a tier, a size, a state. */ badge?: { text: string; hl?: string }; /** * A collapsible section this row belongs to. * * Rows in a section are hidden behind a header that says how many there are. What it is for: * last year's models, which you want reachable and do not want in the way. */ section?: string; disabled?: boolean; } export interface RailPickerOptions { title?: string; /** Columns for the rail. The list takes the rest. */ railWidth?: number; /** Total inner width. */ width?: number; /** Rows of body. */ height?: number; rail: RailItem[]; /** Which rail entry to open on. */ railAt?: number; /** The rows for a rail entry. Called on open and on every rail move. */ items(group: G): Promise[]>; /** Which row to start on, given the rows. */ itemAt?(items: PaneItem[]): number; /** The key strip at the foot. Keep it to one line. */ hints?: string; /** * What the two panes are called, for the affordance in the title row. * * `"providers"` and `"models"` rather than `"panes"` and `"list"`, wherever the caller knows — * a key labelled with what it reaches is a key somebody presses. */ railLabel?: string; paneLabel?: string; /** * Shown in place of the list when there is nothing in it. * * Not decoration: an empty pane beside a populated rail reads as a broken widget, and the two * reasons it can be empty — your filter matched nothing, or this provider serves nothing until * it can authenticate — need different things done about them. */ placeholder?: string; /** * Keys `onKey` wants that a *binding elsewhere* would otherwise take. * * The raw capture only receives what no keymap claimed, so a caller reaching for `` gets * nothing — that key belongs to the transcript reader. Listing it here binds it to this widget * for as long as the widget is open, the same way its own movement keys are bound. * * Bare letters do not need this and should not use it: they reach `onKey` already, and taking * one means it can never be typed into the filter — which is how a model picker ends up unable * to search for "sonnet". */ ownKeys?: readonly string[]; /** * A key the caller wants for itself, checked before the widget's own. * * Return `"close"` to dismiss, `"reload"` to re-fetch the current list, `"handled"` to redraw, * and nothing at all to let the widget have the key. This is what lets a model picker put "sign * in" and "set effort" on keys without a second modal. */ onKey?( key: KeyContext, ctx: { rail: G | undefined; item: T | undefined }, ): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined; } const RAIL_NS = "neosh.ui.rail"; /** * A picker in two panes: a rail of categories, and the rows belonging to the selected one. * * The shape exists because one flat list cannot answer two questions at once. "Which provider" and * "which model" are different choices with different cardinalities — a dozen providers, hundreds of * models — and flattening them produces a list where the thing you want is thirty rows below a * provider you do not use. * * Both panes are one buffer with a rule down the middle rather than two floats, because two floats * cannot be kept adjacent without each of them knowing the other's width, and neither of them may * measure anything. One buffer makes the rule a character, which is a thing a plugin can place. * * Typing filters the list. `pane_next`/`pane_prev` — ``/`` and `←`/`→` by default — * move between the panes, and the rail's own entries are reachable with the same movement keys as * the list, so nothing here needs a mouse or a chord you have to be told about. */ export async function railPicker( neosh: Neosh, opts: RailPickerOptions, ): Promise { const height = Math.max(3, opts.height ?? 14); const total = Math.max(40, opts.width ?? 84); const railWidth = Math.min(Math.max(12, opts.railWidth ?? 22), total - 24); const paneWidth = total - railWidth - 1; // the rule takes a column const buf = await neosh.buf.create({ name: `[${opts.title ?? "select"}]`, scratch: true, kind: KIND_PICKER }); const ns = await neosh.ns.create(RAIL_NS); const win = await neosh.float.open(buf, { anchor: { kind: "screen" }, width: { kind: "fixed", n: total }, // title, filter, rule, body, rule. The key strip is not among them: it is on the bottom border, // where it costs no row of body and cannot be the row a short terminal clips. height: { kind: "fixed", n: height + 4 }, border: "rounded", footer: ` ${opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"} `, focusable: true, closeOnBlur: true, // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`, // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one, // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that // fails to bind a way out is never a terminal somebody has to kill: see // `ui.modal_escape_keys`. modal: true, z: 200, }); watchKeys(neosh); const keys = await widgetKeys(neosh); const railLabel = opts.railLabel ?? "panes"; const paneLabel = opts.paneLabel ?? "list"; // ---- rail rows, headings interleaved ------------------------------------ type RailRow = | { kind: "heading"; text: string } | { kind: "entry"; item: RailItem; index: number }; const railRows: RailRow[] = []; { let group: string | undefined; opts.rail.forEach((item, index) => { if (item.group !== undefined && item.group !== group) { group = item.group; railRows.push({ kind: "heading", text: item.group }); } railRows.push({ kind: "entry", item, index }); }); } const selectableRail = railRows .map((r, at) => (r.kind === "entry" && !r.item.disabled ? at : -1)) .filter((at) => at >= 0); let railAt = selectableRail.find( (at) => (railRows[at] as { index: number }).index === (opts.railAt ?? 0), ) ?? selectableRail[0] ?? 0; let railTop = 0; // ---- pane state ---------------------------------------------------------- let all: PaneItem[] = []; let open = new Set(); let query = ""; let cursor = 0; let paneTop = 0; /** In the rail, or in the list. */ let focus: "rail" | "pane" = "pane"; type PaneRow = | { kind: "section"; name: string; count: number } | { kind: "item"; item: PaneItem; positions: number[] }; let rows: PaneRow[] = []; const rebuild = () => { const ranked = query === "" ? all.map((item) => ({ item, positions: [] as number[], score: 0 })) : all .map((item) => { const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label; const m = fuzzy(hay, query); return m ? { item, positions: m.positions, score: m.score } : null; }) .filter((x): x is NonNullable => x !== null) .sort((a, b) => b.score - a.score); const out: PaneRow[] = []; const sections = new Map(); for (const r of ranked) { const section = r.item.section; if (section === undefined) { out.push({ kind: "item", item: r.item, positions: r.positions }); continue; } sections.set(section, (sections.get(section) ?? 0) + 1); } // Filtering opens every section: a row you cannot see is a row your query did not find, and // "no matches" while the match sits folded away is the worst possible answer. for (const [name, count] of sections) { const expanded = open.has(name) || query !== ""; out.push({ kind: "section", name, count }); if (!expanded) continue; for (const r of ranked) { if (r.item.section === name) out.push({ kind: "item", item: r.item, positions: r.positions }); } } rows = out; cursor = Math.min(cursor, Math.max(0, rows.length - 1)); }; // Bumped on every rail move, so a slow load cannot land after a faster one that came later. let generation = 0; const load = async () => { const mine = ++generation; const entry = railRows[railAt]; const got = entry?.kind === "entry" ? await opts.items(entry.item.value).catch(() => []) : []; // Holding a movement key starts one load per row, and they finish in whatever order the // network allows — so the last *answer* is routinely not the last *question*. Without this, // pressing down eight times lands you on a provider showing the empty list of a provider you // passed through on the way, which reads as "this one has no models". if (mine !== generation) return; all = got; rebuild(); cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1))); // Land on something you can actually press `↵` on: not a section header, and not a row the // caller disabled. `movePane` has always stepped over disabled rows, so the only way to be // standing on one was to open here — and the first row of a list is where a picker opens by // default. A pane whose top row is "this needs a newer CLI" then greeted every `↵` with // nothing happening, which reads as the picker being broken rather than the row being refused. const landable = (at: number) => { const row = rows[at]; return row?.kind === "item" && !row.item.disabled; }; if (!landable(cursor)) { const next = rows.findIndex((_, at) => landable(at)); // Every row refused is still a list worth opening — you came to read why. Falling back to // the first item rather than to nothing keeps the detail line of *something* on screen. cursor = next >= 0 ? next : rows.findIndex((r) => r.kind === "item"); } if (cursor < 0) cursor = 0; paneTop = 0; }; // ---- drawing ------------------------------------------------------------- const RULE_V = "│"; const railCell = (row: RailRow | undefined, on: boolean): { text: string; marks: Mark[] } => { const marks: Mark[] = []; if (!row) return { text: padToWidth("", railWidth), marks }; if (row.kind === "heading") { const text = padToWidth(` ${row.text}`, railWidth); marks.push({ from: 0, to: byteLength(text), hl: "Sidebar.Heading" }); return { text, marks }; } const { item } = row; const mark = item.mark?.text ?? " "; const badge = item.badge?.text ?? ""; const room = railWidth - 3 - width(mark) - width(badge) - 1; const label = clipToWidth(item.label, Math.max(1, room)); const head = `${on ? "❯" : " "} ${mark} `; const body = `${head}${label}`; const text = padToWidth(body, railWidth - width(badge) - 1) + badge + " "; if (item.mark) { const at = byteLength(`${on ? "❯" : " "} `); marks.push({ from: at, to: at + byteLength(mark), hl: item.mark.hl ?? "Normal" }); } if (item.badge) { const at = byteLength(padToWidth(body, railWidth - width(badge) - 1)); marks.push({ from: at, to: at + byteLength(badge), hl: item.badge.hl ?? "Comment" }); } if (on) { marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 }); } else if (item.disabled) { marks.push({ from: 0, to: byteLength(text), hl: "Comment" }); } return { text, marks }; }; /** * One row of the list, and — when the cursor is on it — the rest of what it had to say. * * `more` is what makes the three columns bearable. A model row is a name, a rung and a sentence * about it inside 45% of half a float, so the sentence is the first thing cut and the sentence is * the whole reason a catalogue of forty models is choosable at all. It goes under the row rather * than beside it, because the columns are what let you read *down* the list and widening one for * the row you are on would move the other two every time you pressed a key. */ const paneCell = ( row: PaneRow | undefined, on: boolean, ): { text: string; marks: Mark[]; more: string[] } => { const marks: Mark[] = []; const more: string[] = []; if (!row) return { text: "", marks, more }; if (row.kind === "section") { const text = ` ${open.has(row.name) || query !== "" ? "▾" : "▸"} ${row.count} ${row.name}`; marks.push({ from: 0, to: byteLength(text), hl: on ? "Picker.Selected" : "Comment" }); return { text, marks, more }; } const { item } = row; const badge = item.badge?.text ?? ""; const detail = item.detail ?? ""; // Three columns: label, badge, detail. The badge column is fixed so the rungs of the ladder // line up — reading down it is how you see the shape of what a provider offers. const badgeWidth = badge === "" ? 0 : 10; const labelWidth = Math.max(10, Math.floor((paneWidth - 3 - badgeWidth) * 0.45)); const head = on ? " ❯ " : " "; const room = paneWidth - width(head) - labelWidth - badgeWidth; // On the cursor's row the columns are broken at words and continued underneath; everywhere else // they are clipped, because a list of forty models is read by running an eye down it. const [labelHead, labelRest] = on ? takeWords(item.label, labelWidth) : [clipToWidth(item.label, labelWidth), ""]; const [detailHead, detailRest] = on ? takeWords(detail, Math.max(0, room)) : [clipToWidth(detail, Math.max(0, room)), ""]; const label = padToWidth(labelHead, labelWidth); // One column narrower than the cell it sits in, so there is always a space before the detail. // Clipped to the full width, a badge that happens to fill it runs straight into the sentence // after it and the two read as one word. const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth - 1), badgeWidth); const text = `${head}${label}${badgeCell}${detailHead}`; const labelAt = byteLength(head); const offsets = byteOffsets(item.label); for (const at of row.positions) { const from = offsets[at]; const to = offsets[at + 1]; if (from === undefined || to === undefined) continue; marks.push({ from: labelAt + from, to: labelAt + to, hl: "Picker.Match", priority: 200 }); } if (badgeWidth > 0) { const at = labelAt + byteLength(label); marks.push({ from: at, to: at + byteLength(badgeCell), hl: item.badge?.hl ?? "Comment" }); } if (detailHead !== "") { const at = labelAt + byteLength(label) + byteLength(badgeCell); marks.push({ from: at, to: byteLength(text), hl: "Picker.Detail" }); } if (item.disabled) marks.push({ from: 0, to: byteLength(text), hl: "Comment" }); if (on) marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 }); if (on) { // The name first, if the name itself did not fit — an id is what you are choosing by, and // half of one is not a thing you can choose by. const nameCol = width(head); if (labelRest.trim() !== "") { for (const line of wrapToWidth(labelRest.trim(), Math.max(8, paneWidth - nameCol - 2))) { more.push(`${" ".repeat(nameCol)}${line}`); } } const detailCol = nameCol + labelWidth + badgeWidth; if (detailRest.trim() !== "") { for (const line of wrapToWidth(detailRest.trim(), Math.max(8, paneWidth - detailCol - 1))) { more.push(`${" ".repeat(detailCol)}${line}`); } } } return { text, marks, more }; }; interface Mark { from: number; to: number; hl: string; priority?: number; } const render = async () => { // Each pane scrolls on its own: moving down a long model list must not scroll the rail out // from under the provider you are looking at. if (railAt < railTop) railTop = railAt; if (railAt >= railTop + height) railTop = railAt - height + 1; // The unfolded row is paid for out of the list rather than out of the float: growing the float // instead would move the key strip under the reader's eyes on every keystroke, and the strip // is the row that says how to get out. const unfolded = focus === "pane" ? paneCell(rows[cursor], true).more.length : 0; const room = Math.max(1, height - unfolded); if (cursor < paneTop) paneTop = cursor; if (cursor >= paneTop + room) paneTop = cursor - room + 1; const lines: string[] = []; const marks: { line: number; mark: Mark }[] = []; // The title row, and at its right edge the key for *the pane you are not in*. Live rather // than a legend: the shortcut row below can only say the key exists, and a two-pane widget // whose second pane has no visible way in is a pane nobody finds. const title = opts.title ?? ""; const cross = focus === "pane" ? `${keyLabel(keys, "pane_prev")} ${railLabel}` : `${keyLabel(keys, "pane_next")} ${paneLabel}`; const gap = Math.max(1, total - width(title) - width(cross)); lines.push(`${title}${" ".repeat(gap)}${cross}`); marks.push({ line: 0, mark: { from: 0, to: byteLength(title), hl: "Float.Title" } }); marks.push({ line: 0, mark: { from: byteLength(title) + gap, to: byteLength(lines[0]!), hl: "Composer.HintKey", }, }); lines.push(`> ${query}`); lines.push("─".repeat(railWidth) + "┬" + "─".repeat(paneWidth)); marks.push({ line: 2, mark: { from: 0, to: byteLength(lines[2]!), hl: "Separator" } }); const empty = rows.length === 0 ? ` ${query === "" ? (opts.placeholder ?? "nothing here") : "no matches"}` : null; // The two columns are filled independently and then zipped, because they no longer advance // together: the pane's cursor row is several lines tall when it has unfolded, and the rail // beside it must keep listing providers one per line rather than skipping the ones the // unfolded row happened to sit across. const paneLines: { text: string; marks: Mark[] }[] = []; if (empty !== null) { paneLines.push({ text: empty, marks: [{ from: 0, to: byteLength(empty), hl: "Comment" }] }); } else { for (let i = paneTop; i < rows.length && paneLines.length < height; i++) { const on = focus === "pane" && i === cursor; const cell = paneCell(rows[i], on); paneLines.push({ text: cell.text, marks: cell.marks }); if (!on) continue; for (const text of cell.more) { paneLines.push({ text, marks: [{ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 }], }); } } } for (let i = 0; i < height; i++) { const left = railCell(railRows[railTop + i], focus === "rail" && railTop + i === railAt); const right = paneLines[i] ?? { text: "", marks: [] as Mark[] }; const line = lines.length; lines.push(`${left.text}${RULE_V}${right.text}`); for (const m of left.marks) marks.push({ line, mark: m }); const shift = byteLength(left.text) + byteLength(RULE_V); marks.push({ line, mark: { from: byteLength(left.text), to: shift, hl: "Separator" } }); for (const m of right.marks) { marks.push({ line, mark: { from: m.from + shift, to: m.to + shift, hl: m.hl, priority: m.priority } }); } } lines.push("─".repeat(railWidth) + "┴" + "─".repeat(paneWidth)); marks.push({ line: lines.length - 1, mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" }, }); // The marks were already collected against their rows; they now travel with them. One call // rather than one per mark, so there is no frame in which the text has arrived and the colour // has not — an unmarked row draws in `Normal`, which is near-white. const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] })); for (const { line, mark } of marks) { drawn[line]?.marks!.push({ col: mark.from, opts: { hlGroup: mark.hl, endCol: mark.to, priority: mark.priority ?? 0 }, }); } await neosh.buf.render(buf, ns, 0, -1, drawn); // The caret belongs where the typing goes. Parked at the origin, the filter reads as inert. await neosh.win.setCursor(win, 1, byteLength(`> ${query}`)); }; // ---- keys ---------------------------------------------------------------- let settle: (v: T | null) => void = () => {}; const done = new Promise((resolve) => { settle = resolve; }); const command = `${RAIL_NS}.key.${++pickerSeq}`; const disposers: Disposable[] = []; let closed = false; const close = async (value: T | null) => { if (closed) return; closed = true; for (const d of disposers) d.dispose(); await neosh.win.close(win).catch(() => {}); settle(value); }; const moveRail = async (delta: number) => { const at = selectableRail.indexOf(railAt); const next = selectableRail[Math.min(selectableRail.length - 1, Math.max(0, at + delta))]; if (next === undefined || next === railAt) return; railAt = next; // The filter belongs to the list that was showing. Carrying it to a different provider hides // most of what you just switched to, for a reason that has scrolled off the screen. query = ""; await load(); }; /** * Move the list cursor, stepping over rows that cannot be landed on. * * Section headers *are* landable — `↵` on one folds it — so the only thing skipped is a row the * caller marked unusable. Skipping in the direction of travel, and giving up at the end rather * than reversing, so holding a movement key never bounces. */ const movePane = (delta: number) => { const step = delta > 0 ? 1 : -1; let at = cursor; for (let n = 0; n < Math.abs(delta); n++) { let candidate = at + step; while (candidate >= 0 && candidate < rows.length) { const row = rows[candidate]; if (row && !(row.kind === "item" && row.item.disabled)) break; candidate += step; } if (candidate < 0 || candidate >= rows.length) break; at = candidate; } cursor = Math.max(0, Math.min(Math.max(0, rows.length - 1), at)); }; const currentItem = (): PaneItem | undefined => { const row = rows[cursor]; return row?.kind === "item" ? row.item : undefined; }; disposers.push( await neosh.cmd.register(command, async (_args, key) => { if (!key) return; const railEntry = railRows[railAt]; const outcome = await opts.onKey?.(key, { rail: railEntry?.kind === "entry" ? railEntry.item.value : undefined, item: currentItem()?.value, }); if (outcome === "close") { await close(null); return; } if (outcome === "reload") { await load(); await render(); return; } if (outcome === "handled") { await render(); return; } switch (actionFor(keys, key.key, RAIL_ACTIONS)) { case "dismiss": await close(null); return; case "accept": { const row = rows[cursor]; if (focus === "rail") { focus = "pane"; break; } if (row?.kind === "section") { if (open.has(row.name)) open.delete(row.name); else open.add(row.name); rebuild(); break; } const item = row?.kind === "item" ? row.item : undefined; if (!item || item.disabled) return; await close(item.value); return; } case "pane_next": focus = "pane"; break; case "pane_prev": focus = "rail"; break; case "next": if (focus === "rail") await moveRail(1); else movePane(1); break; case "prev": if (focus === "rail") await moveRail(-1); else movePane(-1); break; case "page_down": if (focus === "pane") movePane(height); break; case "page_up": if (focus === "pane") movePane(-height); break; case "first": if (focus === "pane") cursor = Math.max(0, rows.findIndex((r) => r.kind === "item")); break; case "last": if (focus === "pane") cursor = rows.length - 1; break; case "clear": query = ""; rebuild(); break; case "delete_word": query = dropSegment(query); rebuild(); break; default: { if (key.key.code.kind === "backspace") { query = query.slice(0, -1); rebuild(); break; } if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return; // Typing is about the list, wherever the focus is: nobody switches panes first. focus = "pane"; query += key.key.code.c; cursor = 0; rebuild(); if (rows[cursor]?.kind !== "item") movePane(1); break; } } await render(); }, { desc: "rail picker key" }), ); await neosh.focus.push(win); disposers.push(await neosh.keymap.capture(win, command)); await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []); await load(); await render(); return done; } // --------------------------------------------------------------------------- // What a panel owes the plugins that build on it // // A section somebody contributed, placed by name; a decoration somebody put on one of the panel's // own rows; the row itself with the decoration applied. Three functions any list panel can use — // the bundled sidebar does — so that `sidebar.section`, `acme.tasks.section` and every other // `.section` mean exactly the same thing to the plugin contributing to them. // --------------------------------------------------------------------------- /** A block of rows somebody else contributed to a panel. Data, so it can be listed and disabled. */ export interface SectionItem { /** Drawn as a heading with a rule under it. Omit for rows with no heading. */ title?: string; /** The way in, drawn dim at the right of the heading: `^L`, `^G`. */ hint?: string; /** Coarse placement relative to the panel's own blocks. Defaults to `below`. */ at?: "above" | "below"; /** Finer: sit directly before or after one of the panel's slots, or another section's id. */ before?: string; after?: string; rows?: Array<{ text: string; hl?: string; /** Highlights for pieces of the row: UTF-8 byte offsets into *your* `text`. */ spans?: Array<{ from: number; to: number; hl: string }>; right?: { text: string; hl?: string }; /** Run on `↵`. Without one the row is inert — a label rather than a verb. */ command?: string; args?: string[]; }>; } /** A verb on a panel's rows, contributed by somebody else and bound by the panel. */ export interface ActionItem { /** Key notation, as `keymap.set` takes it. */ key: string; /** For the hint strip and `^Z`. */ label: string; command: string; /** * Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`. * * `custom:
` narrows it to the rows of **one** section: `custom:git` is a verb about * the git block and nothing else. Bare `custom` means every contributed row in the panel, which * is right for a verb about contributions in general and wrong for the usual case — two plugins * each with a block in the column and each wanting `` on their own rows were, without this, * one key that one of them won and the other silently lost. A panel binds the key once and sends * it to whichever action matches the row the cursor is actually on. */ on?: string; } /** One coloured run of a badge. A part with no `hl` is the panel's own colour — a separator. */ export interface BadgePart { text: string; hl?: string; } /** * A mark after a row's name: one coloured run, or several. * * Several, because a badge is often several facts rather than one — `↓2` is not the same news as * `~7`, and a strip of git stats drawn in one colour is a string you have to read rather than a * row you can glance at. The single form stays because most badges *are* one fact. * * `short` is the same badge with less in it, for a row too narrow to carry all of it — the fewest * parts still worth drawing, not a truncation. A panel is what knows how many columns there are; * which fact to give up is the badge's to say, exactly as it is for a status segment. Absent means * there is no shorter true version, and the full badge stands. */ export type BadgeSpec = | { text: string; hl?: string } | { parts: BadgePart[]; short?: BadgePart[] }; /** A mark on a row a panel already draws, keyed by what the row is about. */ export interface DecorationItem { /** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */ target: Record; /** A short mark after the name. The name is clipped to make room. */ badge?: BadgeSpec; /** The row's highlight, when the panel has no opinion of its own. */ hl?: string; /** The right-hand column, on a row that is not busy. */ right?: { text: string; hl?: string }; } /** Every decoration on one target, merged. */ export interface Decoration { /** `text` is every part run together — what a caller measuring the badge reads. */ badge?: { text: string; hl?: string; parts: BadgePart[]; short: BadgePart[] }; hl?: string; right?: { text: string; hl?: string }; } /** A badge in either form, as the run of parts the rest of this file works in. */ function badgeParts(badge: BadgeSpec | undefined): BadgePart[] { if (!badge || typeof badge !== "object") return []; const many = (badge as { parts?: unknown }).parts; if (Array.isArray(many)) { return many.filter((p): p is BadgePart => Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" && (p as BadgePart).text !== "" ); } const one = badge as { text?: unknown; hl?: unknown }; if (typeof one.text !== "string" || one.text === "") return []; return [{ text: one.text, hl: typeof one.hl === "string" ? one.hl : undefined }]; } /** * A badge's short form, falling back to the whole of it. * * The fallback is what lets several decorations merge without anybody declaring one: a plugin with * nothing to give up keeps all of its parts in both forms, and {@link fitBadge} notices that the * two are the same width and leaves the badge alone. */ function badgeShort(badge: BadgeSpec | undefined): BadgePart[] { const declared = (badge as { short?: unknown } | undefined)?.short; if (!Array.isArray(declared)) return badgeParts(badge); return declared.filter((p): p is BadgePart => Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" && (p as BadgePart).text !== "" ); } /** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */ export function decorationKey(target: unknown): string | null { if (!target || typeof target !== "object") return null; const entries = Object.entries(target as Record) .filter(([, v]) => typeof v === "string") .sort(([a], [b]) => a.localeCompare(b)); const first = entries[0]; return first ? `${first[0]}:${first[1] as string}` : null; } /** * Every decoration on a point, folded by target: later contributions (lower priority) fill in what * earlier ones left unsaid, and badges are joined with a space, because two plugins each with one * word to say about a row are both right. */ export function mergeDecorations( items: Array, ): Map { const out = new Map(); for (const c of items) { const key = decorationKey(c.item?.target); if (key === null) continue; const d = out.get(key) ?? {}; const parts = badgeParts(c.item.badge); if (parts.length > 0) { const short = badgeShort(c.item.badge); const merged = d.badge ? [...d.badge.parts, { text: " " }, ...parts] : parts; d.badge = { text: merged.map((p) => p.text).join(""), hl: d.badge?.hl ?? parts[0]?.hl, parts: merged, short: d.badge ? [...d.badge.short, { text: " " }, ...short] : short, }; } if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl; if (d.right === undefined && typeof c.item.right?.text === "string") { d.right = { text: c.item.right.text, hl: c.item.right.hl }; } out.set(key, d); } return out; } /** * How many columns a decoration's badge will take, for a row builder to leave free. * * Columns rather than bytes, which is what a row builder is subtracting this from: `●3` is four * bytes and two columns, and a strip of arrows is three bytes each — measured in bytes, a badge * that says `↓2 ↑5 ~7` takes eleven columns off a name to occupy nine. */ export function badgeWidth(d: Decoration | undefined): number { return d?.badge ? width(` ${d.badge.text}`) : 0; } /** * The badge as this row can actually afford it: the whole of it, or its short form. * * **The badge yields before the name does.** A row's name is what the row *is* — a project you * cannot read the name of is not a row you can use — and a badge is news about it. So the full * strip is drawn whenever the name fits beside it, and a name that would be clipped by the badge * gets the short form instead. Only if it still does not fit is the name clipped, which is the * panel's own business and unchanged. * * `room` is every column the name and the badge have between them, `name` the columns the name * wants. A badge whose short form saves nothing is left alone, which is what makes the fallback in * {@link badgeShort} safe: a plugin that declared no short form never has one chosen for it. */ export function fitBadge( d: Decoration | undefined, room: number, name: number, ): Decoration | undefined { if (!d?.badge) return d; const full = badgeWidth(d); if (name + full <= room) return d; const short = { ...d.badge, text: d.badge.short.map((p) => p.text).join("") }; if (width(` ${short.text}`) >= full) return d; return { ...d, badge: { ...short, parts: d.badge.short } }; } /** * A decoration, applied to a row the panel built. * * The badge goes on the end of the clipped text with its own span — the builder left room for it * with {@link badgeWidth}. `hl` only fills a row the panel left plain; `right` only replaces the * column on a row that is not `busy`, because a row's own state — working, asking, failed — is * what the column exists to show. */ export function decorateRow( row: ListRow, d: Decoration | undefined, busy = false, ): ListRow { if (!d) return row; if (d.badge) { const mark = ` ${d.badge.text}`; // Past the space this puts between the name and the badge: it is the panel's colour, not the // badge's, and a span over it would be one colour claiming a column it does not fill. let at = byteLength(row.text) + 1; row.text = `${row.text}${mark}`; // Not onto `full`. The badge goes *after* the clip — past the ellipsis, on the end of the row, // where an unread dot or a count already sits — so it survives the clip and is on screen // whether or not the name was cut. `overflowOf` compares the two strings as prefixes of each // other, and a `full` that ends in the badge while `text` has a star and a mark between the // name and the badge is a prefix of nothing: the whole of `full` was then said again under the // cursor row, name and badge and all. const spans = [...(row.spans ?? [])]; for (const part of d.badge.parts) { const to = at + byteLength(part.text); if (part.hl) spans.push({ from: at, to, hl: part.hl }); at = to; } if (spans.length > 0) row.spans = spans; } if (d.hl && row.hl === undefined) row.hl = d.hl; if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl }; return row; } /** * Where every contributed section goes, as a walk over a panel's slots. * * `before`/`after` names a slot or another section's id; `at` is the coarse version. An anchor on * another section resolves once that section has found its place, so a chain settles in a pass or * two, and a section whose anchor never appears falls back to `at`. Sections with the same * placement keep the registry's priority order. */ export function placeSections( slots: readonly S[], sections: Array, ): Array { type Entry = S | (Contribution & { item: SectionItem }); const order: Entry[] = [...slots]; const idOf = (e: Entry) => (typeof e === "string" ? e : e.id); const indexOf = (name: string) => order.findIndex((e) => idOf(e) === name); const first = slots[0]; let pending = sections.filter((c) => c.item && typeof c.item === "object"); for (let pass = 0; pass < 8 && pending.length > 0; pass++) { const next: typeof pending = []; for (const c of pending) { const before = typeof c.item.before === "string" ? indexOf(c.item.before) : -1; const after = typeof c.item.after === "string" ? indexOf(c.item.after) : -1; if (before >= 0) order.splice(before, 0, c); else if (after >= 0) order.splice(after + 1, 0, c); else if (typeof c.item.before === "string" || typeof c.item.after === "string") { next.push(c); } else if ((c.item.at ?? "below") === "above" && first !== undefined) { order.splice(indexOf(first), 0, c); } else { order.push(c); } } if (next.length === pending.length) { for (const c of next) { if ((c.item.at ?? "below") === "above" && first !== undefined) { order.splice(indexOf(first), 0, c); } else order.push(c); } break; } pending = next; } return order; } /** * Rows for a contributed section, every field checked rather than trusted. * * A contribution is JSON from a plugin the panel has never heard of, and a panel that throws on a * missing `text` is a panel a third party can break by getting one row wrong. */ export function sectionRows( c: Contribution & { item: SectionItem }, opts: { width: number; /** * What a landable contributed row *is*, in the panel's own vocabulary. * * The section's id comes third so a panel can tell one contributor's rows from another's — * which is what {@link ActionItem.on}'s `custom:` form is matched against. */ custom: (command: string, args: string[], section: string) => T; }, ): ListRow[] { const rows: ListRow[] = []; const contributed = Array.isArray(c.item?.rows) ? c.item.rows : []; if (contributed.length === 0 && !c.item?.title) return rows; rows.push({ text: "", inert: true }); if (typeof c.item.title === "string" && c.item.title !== "") { const hint = typeof c.item.hint === "string" ? c.item.hint : undefined; rows.push({ text: ` ${clipToWidth(c.item.title, Math.max(1, opts.width - 2))}`, hl: "Sidebar.Heading", right: hint ? { text: `${hint} `, hl: "Sidebar.Dim" } : undefined, inert: true, }); rows.push({ text: "─".repeat(Math.max(1, opts.width)), hl: "Separator", inert: true }); } const margin = " "; for (const r of contributed) { if (typeof r?.text !== "string") continue; const text = `${margin}${clipToWidth(r.text, Math.max(4, opts.width - 2 - margin.length))}`; const eol = byteLength(text); const spans = Array.isArray(r.spans) ? r.spans.flatMap((sp) => { if (typeof sp?.from !== "number" || typeof sp?.to !== "number" || typeof sp?.hl !== "string") { return []; } const from = Math.max(0, Math.floor(sp.from)) + byteLength(margin); const to = Math.min(Math.max(0, Math.floor(sp.to)) + byteLength(margin), eol); return to > from ? [{ from, to, hl: sp.hl }] : []; }) : []; const args = Array.isArray(r.args) ? r.args.filter((a): a is string => typeof a === "string") : []; rows.push({ text, full: `${margin}${r.text}`, indent: margin.length, hl: typeof r.hl === "string" ? r.hl : undefined, spans: spans.length > 0 ? spans : undefined, right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined, inert: typeof r.command !== "string", value: typeof r.command === "string" ? opts.custom(r.command, args, c.id) : undefined, }); } return rows; } // --------------------------------------------------------------------------- // ListPanel // --------------------------------------------------------------------------- /** What a {@link ListPanel} needs to know about its own rows. */ export interface ListPanelOptions { /** The buffer kind — `acme.tasks`. Everything else hangs off it: the points are * `.section`, `.action`, `.decoration`; the verbs are `.up` and so on; * the cursor event is `.cursor`. */ kind: string; /** Buffer name. Defaults to `[]`. */ name?: string; dock?: "left" | "right" | "bottom"; /** Columns (or rows, for a bottom dock). Asked on every open and draw. */ size?: () => number | Promise; /** The panel's own rows, rebuilt on every draw. A row's `value` is what every verb receives. */ rows: () => ListRow[] | Promise[]>; /** A row's identity, for anchoring the cursor across redraws and filing decorations: * `{ project: cwd }` becomes `project:`. */ key?: (value: T) => Record | null; /** The row kind a contributed action's `on` may name: `"project"`, `"session"`. */ kindOf?: (value: T) => string; /** The arguments a contributed action receives for a row. Defaults to `[kindOf(value), ...key values]`. */ argsFor?: (value: T) => string[]; /** `↵` on a row. A contributed row runs its own command instead. */ onOpen?: (value: T) => void | Promise; /** Named blocks a section may sit `before`/`after`. Defaults to `["main"]`; `rows` fill the first. */ slots?: readonly string[]; cursorHl?: string; /** Open the panel as soon as it is created. Default true. */ open?: boolean; } /** A contributed row's value, for panels whose own rows are something else. */ export type CustomRow = { kind: "custom"; command: string; args: string[] }; /** * A docked list panel that is a surface, not a program — the three panel mechanisms, and a * published cursor, for the price of a kind and a `rows` function. * * Given `kind: "acme.tasks"`, this creates the buffer with that kind, opens it in a dock, binds * every verb as a named command at `buf_kind` scope (`acme.tasks.down`, `.up`, `.first`, `.last`, * `.open`, `.leave`, `.toggle`, `.focus`, `.refresh`, `.cursor`, `.rows`) so `^Z` lists them and * `init.ts` can move them, reads `acme.tasks.section` / `.action` / `.decoration` exactly as the * sidebar reads its own, publishes the row under the cursor as the buffer var `cursor` and the * event `acme.tasks.cursor`, and redraws when any of that changes. What is left for the plugin is * what the rows say. */ export class ListPanel { private buf!: BufferId; private ns!: number; private list!: CursoredList; private win: WindowId | null = null; private focused = false; private capture: Disposable | null = null; private width = 30; private actions: Array = []; private bound: Array<{ key: string; command: string }> = []; private registered: Disposable[] = []; private drawing = false; private again = false; readonly subscriptions: Disposable[] = []; private constructor( private readonly neosh: Neosh, private readonly opts: ListPanelOptions, ) {} /** Create the panel: buffer, verbs, points, and — unless `open: false` — the window. */ static async create(neosh: Neosh, opts: ListPanelOptions): Promise> { const p = new ListPanel(neosh, opts); await p.setup(); if (opts.open !== false) await p.open(); return p; } get kind(): string { return this.opts.kind; } /** The contribution points this panel reads. */ get points(): { section: string; action: string; decoration: string } { const k = this.opts.kind; return { section: `${k}.section`, action: `${k}.action`, decoration: `${k}.decoration` }; } /** The row under the cursor, or `null`. */ get cursor(): T | CustomRow | null { return this.list.value ?? null; } /** Every row that can be landed on. */ get rows(): Array { return this.list.values; } isOpen(): boolean { return this.win !== null; } private keyOf(value: T | CustomRow): string | null { if (this.isCustom(value)) return null; return decorationKey(this.opts.key?.(value) ?? null); } private isCustom(value: T | CustomRow): value is CustomRow { return typeof value === "object" && value !== null && (value as CustomRow).kind === "custom" && typeof (value as CustomRow).command === "string"; } private async setup(): Promise { const { neosh, opts } = this; const k = opts.kind; this.buf = await neosh.buf.create({ name: opts.name ?? `[${k}]`, scratch: true, kind: k }); this.ns = await neosh.ns.create(k); this.list = new CursoredList(neosh, this.buf, this.ns, { cursorHl: opts.cursorHl, width: () => this.width, onMove: () => this.published(), }); const scope = { kind: "buf_kind", name: k } as const; const verb = async (name: string, keys: string[], desc: string, fn: () => void | Promise, redraw = true) => { this.subscriptions.push( await neosh.cmd.register(`${k}.${name}`, async () => { await fn(); if (redraw) await this.draw(); }, { desc }), ); for (const key of keys) { await neosh.keymap.set("chat", key, `${k}.${name}`, { scope, desc }); } }; await verb("down", ["j", "", ""], "Next row", () => this.list.move(1)); await verb("up", ["k", "", ""], "Previous row", () => this.list.move(-1)); await verb("first", ["gg"], "First row", () => this.list.toEnd("first")); await verb("last", ["G"], "Last row", () => this.list.toEnd("last")); await verb("open", [""], "Open the row under the cursor", async () => { const v = this.list.value; if (v === undefined) return; if (this.isCustom(v)) { await neosh.cmd.exec(v.command, v.args).catch((e: unknown) => neosh.notify(String(e), "warn")); } else { await opts.onOpen?.(v); } }); await verb("leave", ["", "q"], "Back to the composer", () => this.leave(), false); await verb("refresh", [], "Redraw the panel now", () => {}, true); this.subscriptions.push( await neosh.cmd.register(`${k}.toggle`, () => (this.isOpen() ? this.close() : this.open()), { desc: "Show or hide the panel", }), ); this.subscriptions.push( await neosh.cmd.register(`${k}.focus`, () => this.enter(), { desc: "Move into the panel" }), ); this.subscriptions.push( await neosh.cmd.register(`${k}.cursor`, () => this.cursor, { desc: "The row under the cursor" }), ); this.subscriptions.push( await neosh.cmd.register(`${k}.rows`, () => this.rows, { desc: "Every row you can land on" }), ); // A sink for the keys nothing claimed, so an unbound letter does not fall through to the // composer and start a message. this.subscriptions.push( await neosh.cmd.register(`${k}.key`, () => {}, { desc: "Swallow an unbound key in the panel" }), ); const { section, action, decoration } = this.points; this.subscriptions.push( neosh.ext.onChange((e) => { if (e.point === section || e.point === decoration) void this.draw(); if (e.point === action) void this.syncActions(); }), ); this.subscriptions.push( neosh.event.on("neosh.viewport", (e) => { const d = e.data as { win?: number; width?: number } | null; if (d?.win === this.win && typeof d.width === "number") { this.width = d.width; void this.draw(); } }), ); await this.syncActions(); this.subscriptions.push({ dispose: () => { for (const d of this.registered) d.dispose(); for (const b of this.bound) void neosh.keymap.del("chat", b.key, scope).catch(() => {}); }, }); } /** Bind the verbs other plugins contributed, on their behalf, with the row as arguments. */ private async syncActions(): Promise { const { neosh, opts } = this; const k = opts.kind; const scope = { kind: "buf_kind", name: k } as const; const got = await neosh.ext.list(this.points.action).catch(() => []); for (const b of this.bound) await neosh.keymap.del("chat", b.key, scope).catch(() => {}); for (const d of this.registered) d.dispose(); this.bound = []; this.registered = []; const mine = new Set( (await neosh.keymap.list("chat").catch(() => [])) .filter((m) => m.scope.kind === "buf_kind" && m.scope.name === k && !m.command.startsWith(`${k}.action.`)) .map((m) => m.lhs), ); this.actions = got.filter((c) => typeof c.item?.key === "string" && typeof c.item?.command === "string"); for (const c of this.actions) { const name = `${k}.action.${c.plugin}.${c.id}`; const reg = await neosh.cmd.register(name, async () => { const v = this.list.value; if (v === undefined) return; const on = c.item.on ?? "any"; const rowKind = this.isCustom(v) ? "custom" : opts.kindOf?.(v) ?? "row"; if (on !== "any" && on !== rowKind) return; const args = this.isCustom(v) ? ["custom", ...v.args] : opts.argsFor?.(v) ?? [rowKind, ...Object.values(opts.key?.(v) ?? {})]; await neosh.cmd.exec(c.item.command, args).catch((e: unknown) => neosh.notify(String(e), "warn")); await this.draw(); }, { desc: c.item.label }).catch(() => null); if (reg) this.registered.push(reg); if (mine.has(c.item.key)) { neosh.log.warn(`${c.plugin} asked for '${c.item.key}' in ${k}, which is already a panel key`); continue; } await neosh.keymap.set("chat", c.item.key, name, { scope, desc: c.item.label }).catch(() => {}); this.bound.push({ key: c.item.key, command: name }); } await this.draw(); } private published(): void { const v = this.list.value ?? null; void this.neosh.vars.set({ scope: "buffer", buf: this.buf }, "cursor", v).catch(() => {}); void this.neosh.event.emit(`${this.opts.kind}.cursor`, v); } /** Redraw: the plugin's rows, decorated, with contributed sections placed among the slots. */ async draw(): Promise { if (this.win === null) return; if (this.drawing) { this.again = true; return; } this.drawing = true; try { do { this.again = false; const { neosh, opts } = this; const own = await opts.rows(); const [sections, decorations] = await Promise.all([ neosh.ext.list(this.points.section).catch(() => []), neosh.ext.list(this.points.decoration).catch(() => []), ]); const merged = mergeDecorations(decorations); const slots = opts.slots ?? ["main"]; const order = placeSections(slots, sections); const rows: ListRow[] = []; let body = 0; for (const entry of order) { if (typeof entry === "string") { if (entry === slots[0]) { for (const r of own) { const key = r.value === undefined ? null : this.keyOf(r.value); rows.push(key === null ? r : decorateRow(r, merged.get(key))); } } body = rows.length; } else { rows.push(...sectionRows(entry, { width: this.width, custom: (command, args) => ({ kind: "custom", command, args }), })); } } this.list.setRows(rows, (a, b) => { const ka = this.keyOf(a); return ka !== null && ka === this.keyOf(b); }); await this.list.render({ showCursor: this.focused, win: this.win ?? undefined, pinned: rows.length - body }); } while (this.again); } finally { this.drawing = false; } } async open(): Promise { if (this.win !== null) return; const size = (await this.opts.size?.()) ?? 30; this.width = size; this.win = await this.neosh.win.open(this.buf, this.opts.dock ?? "left", { size }); await this.draw(); } async close(): Promise { if (this.win === null) return; if (this.focused) await this.leave(); await this.neosh.win.close(this.win).catch(() => {}); this.win = null; } /** Take the keyboard. */ async enter(): Promise { if (this.win === null) await this.open(); if (this.win === null || this.focused) return; await this.neosh.focus.push(this.win); this.capture = await this.neosh.keymap.capture(this.win, `${this.opts.kind}.key`); this.focused = true; this.published(); await this.draw(); } /** Give it back. */ async leave(): Promise { if (!this.focused) return; this.focused = false; this.capture?.dispose(); this.capture = null; await this.neosh.focus.pop(); await this.draw(); } /** Close the window and unregister everything. */ dispose(): void { void this.close(); for (const d of this.subscriptions) d.dispose(); } } ```