Skip to content

feat(core): let a plugin type into a program it started - #1117

Merged
LeTuR merged 2 commits into
Thurbeen:mainfrom
spscream:feat/plugin-program-keys
Sep 13, 2026
Merged

LeTuR merged 2 commits into
Thurbeen:mainfrom
spscream:feat/plugin-program-keys

Conversation

@spscream

Copy link
Copy Markdown
Contributor

The second of the two APIs from #1107's 4a2cacb, rebased onto current main. The restored files are byte-identical to what was taken out; everything else comes from main.

What it is

command("program", { text = …, keys = "…" }) sends bytes to the pane's stdin as if they had been typed at it. The third thing a plugin can do to its own pane, after starting and closing it, and the one that lets a long-lived program be told something rather than replaced.

Restarting an editor to open a second file is the case that asked for it. Starting is idempotent — asking again with different args does nothing, the pane is already there — so without this the only way to change what a program was showing was to close it and pay for the process again.

The contract, and the silent failure you named

You wrote: "'Send keys, or start the program if the pane is not there' is a contract with a silent failure mode on both sides — the keys are dropped when the pane turns out to be missing and the fallback start puts the program somewhere the keys would not have, and a plugin cannot tell which happened."

Here is where that landed:

  • keys alone, pane holds nothing live → it starts nothing and says so. Not a silent drop: the plugin gets the failure back.
  • keys with a program → the coordinator picks, because whether the pane is alive is not something a plugin can read. If it had to start the program, the keys are not sent: a process that has not begun reading would lose them, and a lost keystroke is worse than one never sent.
  • Which of the two happened is therefore not a question a caller has to ask. A start is a start; the keys belong to the pane that was already running.

The consumer you asked to see

It exists and it is in use daily, but it is not in ui/plugins/ — it is in my own interface directory, and it cannot be bundled as it stands. I will say why below. This is the whole call site:

--- The keys lead with two Escapes because we do not know what mode nvim is in:
--- typed in insert mode, `:confirm e ...` would go into the file. `confirm`
--- rather than a bare `e` so unsaved work is asked about instead of refused with
--- a message the tab strip then contradicts.
command("program", {
  text = editor_key(id),
  -- An ABSOLUTE path, built by the tree from the session's own `cwd`:
  -- `thurbox.cmd.Program` carries no session and no directory, so the working
  -- directory this starts in would otherwise have to be guessed.
  repo = "nvim",
  args = { path },
  keys = "\27\27:confirm e " .. escape_for_edit(path) .. "\r",
})

What it shows about the API, which is what you wanted to judge:

  • The two Escapes are the caller's problem, not the API's. A pane's stdin takes bytes; what those bytes mean depends on a mode only the program knows. keys cannot help with that and does not pretend to — which is also why I would not hold this API up as an editor-opening feature.
  • :confirm rather than e — the failure that matters is not the API's, it is a refusal the interface would then contradict.
  • It subscribes to program.exited (merged in fix(core): announce a program pane's exit and give each window the remain-on-exit it needs #1107), because a pane that ends must put the tab back; the two features are each other's other half.
  • It found a real bug through the API rather than in it. The call used to be a close-then-open, reasoned from a premise that turned out false — that a keyed name stays taken after the program in it exits. start_program had been replacing an exited slot all along, and what the close bought was a fresh nvim per file, which was the visible lag.

Why it cannot be bundled as it stands

The path comes from a file tree: the tree emits user.openfile, the centre pane catches it and calls the code above. There is no tree in ui/plugins/ — mine is 1447 lines, plus 326 for the right-button menu, on top of a centre pane 576 lines past the bundled one. A bundled editor tab would be an editor with nothing to open, because nothing in the shipped interface supplies a path.

And the key string is nvim's, not an editor's: :confirm e is vim syntax, so "the bundled editor tab" would either hardcode nvim or need editor detection — a design argument that would drown this review.

So: the honest unit for the bundle is a tree plus an editor tab together, which is a feature proposal, not an API review. Happy to open that conversation once this lands, or sooner if you would rather see them as one thing — but I did not want to make that decision inside a PR about a command field.

Checks

a_program_command_can_type_into_a_pane_it_already_started covers the shape: typing names a pane that is already running, so it asks for no program, and it is not a way to close one. cargo nextest run --all: 2642 tests, failures only the four that reproduce on main in this environment. fmt and clippy --all-targets clean.

on_context is #1116, independent of this one.

`command("program", { text = …, keys = "…" })` sends bytes to the pane's
stdin as if they had been typed at it. The third thing a plugin can do to
its own pane, after starting and closing it, and the one that lets a
long-lived program be *told* something rather than replaced.

Restarting an editor to open a second file is the case that asked for it.
Starting is idempotent, so asking again with different `args` does
nothing — the pane is already there — and the only way to change what a
program was showing was to close it and pay for the process again.

Sent *with* a program it means "type at it, or start it if it is not
running", and the coordinator picks: whether the pane is alive is not
something a plugin can read. The keys are not sent to a program it just
started, because a process that has not begun reading would lose them.
On its own it starts nothing and says so when the pane holds nothing
live, rather than silently dropping the bytes.
@greptile-apps

greptile-apps Bot commented Sep 12, 2026

Copy link
Copy Markdown

Greptile Summary

This revision fixes the previously reported byte-loss issue by preserving Lua strings as raw bytes throughout the program-input path.

  • Changes keys from UTF-8 String values to byte vectors.
  • Passes those bytes unchanged from the Lua host through command parsing to the coordinator.
  • Adds coverage proving that invalid UTF-8 reaches the command intact.
  • Updates plugin documentation and Lua declarations to describe the byte-oriented contract.

Confidence Score: 5/5

The PR appears safe to merge; the previously reported non-UTF-8 byte-loss defect is fixed and no new actionable issue was found.

The Lua boundary now reads keys as mlua::LuaString, stores the value as Vec<u8>, and forwards it unchanged to the pane, with a regression test covering invalid UTF-8. The previous finding was manually resolved without explanation, and the current code fully addresses it.

Important Files Changed

Filename Overview
src/kernel/host/api.rs Reads the keys Lua string directly as bytes, preserving non-UTF-8 input.
src/kernel/command/mod.rs Carries program input as Vec<u8> and retains validation for empty, closing, and fallback commands.
src/coordinator/commands.rs Delivers byte slices unchanged to an existing plugin-owned program pane.
tests/plugin_programs.rs Adds regression coverage showing that a Lua string containing 0xff survives command construction.
docs/PLUGINS.md Documents that keys preserves arbitrary Lua-string bytes.
ui/lib/thurbox.d.lua Extends the Lua program-command declaration with the keys field and fallback semantics.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Plugin Lua byte string] --> B[LuaString]
    B --> C[Command::Program keys: Vec&lt;u8&gt;]
    C --> D[Coordinator]
    D -->|pane running| E[Program stdin]
    D -->|pane absent and repo supplied| F[Start program without sending keys]
    D -->|pane absent and no repo| G[Report error]
Loading

Reviews (2): Last reviewed commit: "fix(core): carry a program's keys as byt..." | Re-trigger Greptile

Comment thread src/kernel/host/api.rs Outdated
`keys` is documented as bytes reaching a program's stdin as if typed, but
it was read off the Lua table through a Rust `String` with the conversion
error swallowed: `t.get::<Option<String>>("keys").ok().flatten()`. A Lua
string is free to hold a sequence that is not UTF-8 — an escape for a
program that speaks its own encoding — and such a field vanished whole.
With `repo` also given, the command then read as "start it" instead of
"type at it", and nothing reported anything.

Read it as an `mlua::LuaString` and carry `Vec<u8>` through `Args`,
`Command::Program` and `apply_program`. The bottom of the chain already
wanted bytes: `send_to_program` takes `Vec<u8>`.

The new test drives the real Lua boundary — a plugin writing
`keys = "\27\255q\r"` — and fails without the fix.

@LeTuR LeTuR left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at df39565a. This is the keys half of the split #1107's review asked for. Nothing here blocks landing it: the four inline comments are all nit:.

The contract question. It is settled for both sides it named. With keys alone and a missing or exited pane, the failure is reported. With keys and a program, the program starts from args, the keys are not sent, and start_program replaces an exited slot, so the keys never land in a pane the plugin did not expect. One narrow path is still silent, raised inline: a live pane whose send fails.

CI on df39565a. 15 passed, 0 failed, 0 cancelled, 0 held. 7 were skipped by path filters: the two install-script jobs, winget, website lint, ShellCheck, SonarQube and PR Title Checker. Nextest and Windows tests and clippy ran. No job sends keys through a real tmux or psmux pane.

Security. No security findings. The owner is stamped from the running plugin, never read from the options table, and trust is checked again when the command is applied. The bytes reach the program's pty through send-keys, and only its output returns, through thurbox's vt100 parser, so nothing reaches the host terminal. Program panes are local only, so SSH and WSL are never reached. The only bound is tmux's 512-byte chunking, but a plugin granted program can already run anything, so typing adds no authority.

The nits are yours to take or leave; merging is the maintainer's call.

— LeTuR's agent

Comment thread src/coordinator/commands.rs
Comment thread docs/PLUGINS.md
Comment thread docs/PLUGINS.md
Comment thread src/kernel/command/mod.rs
@LeTuR
LeTuR merged commit 4d6ae89 into Thurbeen:main Sep 13, 2026
22 checks passed
@LeTuR

LeTuR commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Three things break in the editor-tab call from this PR on real nvim 0.12.5 (plain main 116fdb7, tree replaced by a key emitting user.openfile); opening into the same nvim, insert mode, odd paths, and restart after :q all worked.

  1. An open that arrives while a :confirm prompt is up types nfirm e <path> into the buffer; \28\14 (CTRL-\ CTRL-N) in place of \27\27 avoided it in every mode tried. 2. Escaping only spaces lets a filename run shell commands (|, backticks, raw \r); vim's fnameescape() set plus CTRL-V before control bytes opened all three intact. 3. Stock nvim has hidden on, so :confirm never asks and a later :q stops at E162 with the tab still open.
    Kernel side: the keys-only refusal reaches the status band but not the plugin's command.failed, and keys-only bursts to a running program lose sends under that same "no running program" message (Program keys: send failures are lost, misreported, or never reach the plugin #1119 is the repo twin; 10/10 bursts with repo arrived whole here). An editor that is missing or exits at once is seen as an ordinary exit, not a spawn error.
Per-case output
Case Result
open a, then b same nvim pid, b shown
second open in insert mode opened, b.txt untouched on disk
unsaved buffer, stock nvim no prompt; :ls1 #h + ".../a.txt"
unsaved buffer, nohidden Save changes to ".../b.txt"? [Y]es, (N)o, (C)ancel:
open while that prompt is up buffer line 2 nfirm e /tmp/.../b.txt, [+] 3,1, -- INSERT -- (same in plain nvim, no thurbox)
spaces, quotes, # % $HOME [x] {y} *? with fnameescape set exact file opened
naive escape, q|!date>PWNED-pipe :!date>PWNED-pipe ran, file created
naive escape, r`date>PWNED-tick`.txt backtick expanded, file created
naive escape, s\r:!date>PWNED-cr\r.txt first \r ended the command, :!date ran, file created
fnameescape set + CTRL-V, same three names exact file opened, nothing created
:q / SIGTERM program.exited fired, tab restored
keys + repo after exit nvim restarted with the path, keys not sent
hidden modified buffer, :q E162: No write since last change for buffer ".../a.txt", nvim stays
keys-only, pane dead band ERROR plugins/90_editor.lua has no running program named "editor" to type into; plugin command.failed count 0
1500 keys-only sends to a live cat, 10 bursts 7 lost 29–164 sends, same ERROR text
nvim not on PATH / VIMINIT=cq, 9 starts window created ... window_id=Some("@N") then window closed ms later; program.exited, band empty, command.failed 0; next start logs WARN could not kill program pane %N: ... kill-pane -t %N: can't find pane: %N
F10 that removes an earlier plugin + a press in one input batch (#1118) TRIGGER.on_click(id=editor-body), TRIGGER.on_context(id=editor-body), over the nvim surface TRIGGER.on_click(id=nil); without the removal → EDITOR.on_click

— LeTuR's agent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants