# 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 (15 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 the short version of every binding that ships. - [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 Re-run the install script, or `brew upgrade neosh`, or re-run the `cargo install` command. 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. ## 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. ## 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. ==================== # 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 | ## 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.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" = "" ``` 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". ## 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 contextual key strip at the foot of the panel | | `sidebar.refresh_ms` | `4000` | A workspace re-read per tick | ## 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 | ## 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; `f` pins one to the top, `J`/`K` reorder, and `r` renames a conversation. 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 key strip at the foot of the panel "sidebar.refresh_ms" = 4000 ``` ## 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`, something you can say out loud), 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. ## 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. `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 | | `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` and `codex-cli` drive the vendor's own CLI as a provider. Neither needs an API key, neither stores a token, and neither is offered unless the program is actually on your `$PATH`. Two 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. 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. ## Two 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. ## What you see Conversations on other machines appear in the sidebar under the same project as yours, dimmed, with the host at the end of the row. Projects are matched by normalised git remote, not by path, so `/Users/me/dev/neosh` here and `/home/me/src/neosh` there are one project. `↵` 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. `^N` offers the other machines' checkouts alongside your worktrees, so "start something over there" is one key. ## 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.json<{ branch: string }>( "Return JSON with one key: branch.\n\nUser message:\n" + text, ); ``` `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. 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", or "any" }); ``` 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. ## 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 ==================== Every default is a key your terminal already sends: Ctrl with a letter, `⇧⇥`, `⏎`, `⌫`, `Esc`. Arrows and `PgUp`/`PgDn` work wherever they mean something and are never the only way to do anything. ## Chat | Key | Does | | --- | --- | | `⏎` | Send. While a turn runs, steer it | | `⇧⏎` | Newline, so a pasted snippet stays one message | | `Esc` | Interrupt the turn. The conversation survives it | | `^P` | Pick a model, mid turn too | | `^E` | Every option this model has, applied as you move | | `⇧⇥` | Permission mode for this conversation | | `^N` | New conversation, with a choice of where | | `^T` | Projects and conversations | | `^K` | Command palette | | `^S` | Read the transcript in normal mode | | `^V` | Attach the image on the clipboard | | `^G` | Git status | | `^D` | Show what changed | | `^L` | What the plan has left | | `^B` | Toggle the sidebar | | `^F` | The archive | | `^J` | The computers in this workspace | | `^R` | Reload configuration | | `^Q` | Close this terminal. Everything keeps running | | `^Z` | Every binding, live | `/` completes a command by name. Dragging an image onto the terminal attaches it. ## Reading the transcript `^S` puts the transcript in normal mode. The motions are Vim's and take counts: `5j`, `12G`, `^D`, `w`, `f`, `/` and `n`, `v` to select, text objects like `iw` and `i(`. Nothing edits; the transcript is an artefact you take pieces out of. The copies specific to a transcript: | Key | Copies | | --- | --- | | `y` | The selection | | `yc` | The code block the cursor is in, without its indent | | `ym` | The whole turn, question and answer | | `yp` | This conversation's directory | | `ya` | The entire transcript | `[` and `]` jump by turn, `c` and `C` by tool call, and the card the cursor is in opens itself. `^S` again, or `i`, goes back to the composer. ## The project panel `^T`, then Vim motions with counts. `↵` opens, `n` starts a conversation in the project under the cursor, `r` renames, `f` pins, `x` archives, `X` deletes, `y` copies the row's directory. `?` shows the keys for the row you are on. ## Answering a question When an agent asks you something, a panel opens over the composer. `↵` takes the option under the cursor, `1` to `9` jump to one, `⇥` ticks on a multi-select, and typing is answering: your text goes into the composer and is sent instead. `Esc` tells the agent nobody answered, which it can act on. ## Rebinding Every key is a binding to a command name in one table: ```ts await neosh.keymap.set("chat", "", "chat.clear"); await neosh.keymap.del("chat", ""); ``` The full table lives in [AGENTS.md](https://github.com/neoswarm/neosh/blob/main/AGENTS.md) in the repository. ==================== # Docs for agents URL: https://neoswarm.dev/docs/agents ==================== ## 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, `brisk-otter`, a thing you can say out loud and find again in a list of eight of them — 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. 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: `connecting…` for one being dialled that has never answered — with a `try 4` once it has failed a few times, because a spinner that has been spinning since Tuesday is not a state — `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. Two verbs on any row, and the list updates live while it is open: | 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 — 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. 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` offers the other machines' checkouts alongside your worktrees, so "start something over there" is one key. `↵` 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 offers the worktrees of the repository you are in that are not open yet, and drops through to a path field for anything else. It is the `project.open` command, so `^K` finds it and `pa` can be it. The path field completes as you type: the list under it is the directories matching what you have so far. `` 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. ### 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" = "" ``` 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 ``` 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 Frontier Long-form writing and voice ⬢ Codex ✓ │ Claude Sonnet 5 Balanced Best for everyday tasks API KEYS │ Claude Haiku 4.5 Fast Fastest, for quick answers ✳ Anthropic ! │ ▸ 5 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 Two plans ship: `claude-cli` and `codex-cli`. Both are the vendor's own CLI, driven as a provider. Neither needs an API key, neither stores a token, and neither 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. 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. ## 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.json<{ branch: string }>( 'Return JSON with one key: branch.\n\nUser message:\n' + text, ); ``` 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. 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 // dirty count 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 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. ## 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 ``` Six steps: the workspace test suite, the ts-rs binding drift check, then `tsc` over the plugin API, the example plugin, the bundled plugins, and a freshly scaffolded config. 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. 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 { 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 { 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 { 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 { TurnRequest } from "./generated/TurnRequest"; import type { Usage } from "./generated/Usage"; import type { NodeCapabilities } from "./generated/NodeCapabilities"; import type { NodeId } from "./generated/NodeId"; 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 { WorktreeInfo } from "./generated/WorktreeInfo"; export type { AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo, AgentCommand, AgentState, AgentSummary, Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig, 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, UsageBucket, UsageHistory, UsageResolution, UsageScanSource, Rect, RepoInfo, RepoStatus, SelectShape, SessionId, SessionInfo, StatusAlign, StatusSegment, StopReason, SurfaceCell, SurfaceId, TextEdit, ToolCall, ToolDef, ToolResult, TurnRequest, Usage, NodeCapabilities, NodeId, NodeInfo, ProjectKey, RemoteProject, StreamEvent, SwarmAgent, SwarmNode, SwarmStranger, VarScope, ViewId, ViewInfo, Viewport, WindowId, WindowInfo, WindowLayout, 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; } /** 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; 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; } export interface Neosh { readonly version: number; readonly buf: BufferApi; readonly win: WindowApi; 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; readonly ext: ExtensionApi; readonly event: EventApi; readonly swarm: SwarmApi; readonly quota: QuotaApi; 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; } 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. */ open( buf: BufferId, dock: Dock, opts?: { size?: number; gravity?: Gravity; wrap?: boolean }, ): 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; /** * 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 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. */ pull(opts?: { cwd?: string }): 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; } /** * 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; } /** * 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; } /** 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. */ 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. */ command(node: NodeId, session: string, command: AgentCommand): 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; /** * 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. */ complete(prefix: string): Promise; } 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>; 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: [], 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, close_on_blur: o.closeOnBlur ?? false, focusable: o.focusable ?? true, modal: o.modal ?? 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, }; } /** * 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) { return expect(await c({ call: "path_complete", prefix }), "paths").paths; }, }, 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); }, }, 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 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) { await c({ call: "swarm_command", node, session, command }); }, 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); }, }, 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); }, }, win: { async open(buf, dock, opts) { const layout: WindowLayout = { kind: "docked", 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 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 pull(opts) { const v = await c({ call: "git_pull", cwd: opts?.cwd ?? null }); return expect(v, "text").text; }, async removeWorktree(path, opts) { await c({ call: "git_remove_worktree", path, force: opts?.force ?? false, cwd: opts?.cwd ?? null, }); }, }, 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; }, }, 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 "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, WindowId, } 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 ""; const pretty: Record = { "": "⇥", "": "⇧⇥", "": "←", "": "→", "": "↑", "": "↓", "": "↵", "": "esc", }; // `^N`, not `^n`: nobody presses shift to send it, and the capital is how every terminal // program has written a chord since curses. 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 ( pretty[spec.lhs] ?? spec.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 { const height = 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, a title // when there is one, and the key strip unless it was waived. height: { kind: "fixed", n: height + (filtering ? 1 : 0) + (opts.title ? 1 : 0) + (hints === "" ? 0 : 1), }, border: "rounded", 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 + height) top = cursor - height + 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 + height); // 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, height - 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}`); } // Pushed onto the last row rather than floated: the float is sized for it, and a strip that // moved up as the list shortened would be a strip you have to look for. // // The placeholder takes a row of the list's own space, so an empty list has used one of the // `height` rows and not none. Counting it as none put the strip one row past the bottom of a // float sized for exactly `height`, where it was silently clipped — leaving the empty state, // the one state where you most want to be told what the keys do, as the only one with no keys // on it. // Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and // counting rows here would leave the strip that many lines low — off the bottom of a float // sized for exactly `height`. const listRows = Math.max(1, lines.length - firstListLine); const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows); if (hintLine >= 0) { while (lines.length < hintLine) lines.push(""); lines.push(` ${hints}`); } // 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 }); }; if (hintLine >= 0) { mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") }); } 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 + height); break; case "page_up": cursor = Math.max(0, cursor - height); 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" }), ); 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(); 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); } // --------------------------------------------------------------------------- // 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, ); // 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; const answersAt = detailAt + (detail.length === 0 ? 0 : detail.length + 1); const stripAt = answersAt + answers.length + 1; const win = await neosh.float.open(buf, { anchor: { kind: "screen" }, width: { kind: "fixed", n: width }, height: { kind: "fixed", n: stripAt + 1 }, border: "rounded", 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 lines: string[] = asked.map((l) => ` ${l}`); lines.push(""); if (detail.length > 0) { lines.push(...detail.map((l) => ` ${l}`)); lines.push(""); } answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`)); lines.push(""); lines.push(` ${strip}`); // 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 < detail.length; 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] ?? ""), }); } } mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") }); 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(); 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 }); } /** * 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 { 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 paths = await neosh.path.complete(query).catch(() => []); return 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", 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; 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; } /** * 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[] = []; if (onCursor && eol > 0) { marks.push({ col: 0, opts: { hlGroup: cursorHl, endCol: eol, priority: 200 } }); } if (row.hl && eol > 0) { marks.push({ col: 0, opts: { hlGroup: row.hl, endCol: eol } }); } // Above the row's own highlight, below the cursor's 200: a marker keeps its colour on an // ordinary row and yields to the selection, which is the row the eye is already on. 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: { hlGroup: cursorHl, endCol: end, 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; const top = v.top_line; if (at < top) await this.neosh.win.scrollTo(opts.win, at); else if (end >= top + v.height) { await this.neosh.win.scrollTo(opts.win, end - v.height + 1); } } } } } // --------------------------------------------------------------------------- // 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, hints height: { kind: "fixed", n: height + 5 }, border: "rounded", 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 selectable rather than on a section header. if (rows[cursor]?.kind !== "item") cursor = 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); const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth), 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" }, }); const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"; lines.push(` ${hints}`); marks.push({ line: lines.length - 1, mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" }, }); // 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`. */ on?: string; } /** 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, in `hl`. The name is clipped to make room. */ badge?: { text: string; hl?: string }; /** 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 { badge?: { text: string; hl?: string }; hl?: string; right?: { text: string; hl?: string }; } /** 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 badge = c.item.badge; if (typeof badge?.text === "string" && badge.text !== "") { d.badge = d.badge ? { text: `${d.badge.text} ${badge.text}`, hl: d.badge.hl } : { text: badge.text, hl: badge.hl }; } 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. */ export function badgeWidth(d: Decoration | undefined): number { return d?.badge ? byteLength(` ${d.badge.text}`) : 0; } /** * 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}`; const from = byteLength(row.text); row.text = `${row.text}${mark}`; if (row.full !== undefined) row.full = `${row.full}${mark}`; if (d.badge.hl) { row.spans = [...(row.spans ?? []), { from, to: from + byteLength(mark), hl: d.badge.hl }]; } } 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; custom: (command: string, args: 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) : 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(); } } ```