Skip to content

fix(terminal): own the native terminal buffer and send one notice - #339

Merged
ThomasK33 merged 5 commits into
mainfrom
fix/terminal-failed-start-cleanup
Oct 31, 2026
Merged

ThomasK33 merged 5 commits into
mainfrom
fix/terminal-failed-start-cleanup

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The operator goal is to close issues #333 and #334 with small reviewed PRs. This PR covers the native terminal failure path from #333, items 1, 2, 3, 4, and 6.

open_terminal ran enew in the new split and then treated whatever buffer the split showed as its own. When a BufEnter autocmd swaps another plugin's buffer into the split, a failed start leaves the enew buffer listed. It also deletes the other plugin's buffer when that buffer has no name and no changes. A successful start in the same case turns the other plugin's buffer into the terminal. Every failed start also sent two ERROR notices.

What changed

  • open_terminal creates its buffer with nvim_create_buf, shows it with nvim_win_set_buf, and runs termopen in it through nvim_buf_call. A failed start deletes only that buffer. After a start, the split shows that buffer again if an autocmd swapped it out.
  • open_terminal sends the only notice, "Failed to open native terminal." plus the termopen error. M.open, M.simple_toggle, and M.focus_toggle no longer send "Failed to open Claude terminal using native fallback.".
  • The native spec's mock now behaves like Neovim (BufEnter on buffer entry, termopen turns the current buffer into the terminal). The test titles match their assertions. The external jobstart failure test asserts _get_terminal_for_test() is nil. lua/claudecode/terminal/README.md describes the new behavior.

Scope

Blast Radius

Only the native provider's start path changes. A normal start still gives a listed terminal buffer with bufhidden=hide in the split, with the same on_exit handling. A real Neovim run on main and on this branch shows identical output for that case.

Verification

  • Real Neovim 0.12.3, headless, same script on origin/main and this branch. With an autocmd that swaps in another plugin's scratch buffer and a missing command, main deletes that buffer and leaves an empty listed buffer behind. This branch keeps the plugin buffer and leaves no extra buffer. A working command gives the same terminal buffer, pty size, and exit cleanup on both.
  • :ClaudeCode with provider = "native" and a missing terminal_cmd: main sends 2 ERROR notices, and this branch sends 1, which includes the E475 text.
  • The first commit adds the tests and fails 11 of 22 native cases on main's code. The fix commit makes all 22 pass. mise run check and mise run test (724 tests) pass on the head.

Part of #333


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

Make the native spec's mock behave like Neovim: enew and nvim_win_set_buf
fire a BufEnter hook, termopen turns the current buffer into the terminal,
and nvim_buf_call rewraps a raised error. Add cases for one notice per
failed start from open, simple_toggle, and focus_toggle, for an autocmd
that swaps another plugin's empty buffer into the split, and for the
listed, bufhidden=hide terminal buffer on success.

The external jobstart failure test now asserts _get_terminal_for_test()
is nil instead of get_active_bufnr(), which external always returns nil.

These cases fail on this commit. The next commit fixes native.lua.

Part of #333.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
open_terminal ran enew in the new split and then treated whatever buffer
the split showed as its own. A BufEnter autocmd that swaps another
plugin's buffer into the split broke that: a failed start left the enew
buffer listed and deleted the other plugin's buffer when it had no name
and no changes, and a successful start turned that buffer into the
terminal.

The provider now creates its buffer with nvim_create_buf, shows it with
nvim_win_set_buf, runs termopen in it through nvim_buf_call, and shows it
in the split again if an autocmd swapped it out. A failed start deletes
only that buffer. A normal start behaves as before: a listed terminal
buffer with bufhidden=hide in the split, and the same on_exit handling.

A failed start now sends one ERROR notice, from open_terminal, with the
termopen error text. M.open, M.simple_toggle, and M.focus_toggle no
longer add "Failed to open Claude terminal using native fallback.".

Part of #333.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T17:55:31.168404Z c523434 Manual request
🔒 Security Review ✅ Completed 2026-10-31T17:56:17.731756Z c523434 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 0fb132acb3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 0fb132acb3

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

…termopen

The nvim_buf_call mock now raises "Invalid buffer id" for a deleted
buffer, as Neovim does. The new case swaps another buffer into the split
and wipes the provider's buffer, then expects one failure notice, the
split closed, and the other buffer kept.

Part of #333.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
A BufEnter or BufWinEnter autocmd can swap another buffer into the new
split and wipe the provider's buffer before termopen runs. nvim_buf_call
then raised "Invalid buffer id" as a raw Lua error and left the split
open. open_terminal now checks the buffer first and runs the normal
failure path: one ERROR notice, the split closed, and its state cleared.

Part of #333.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
The mock said every buffer was invalid, so the provider's new buffer
looked deleted and the wiped-buffer check skipped termopen. Only buffers
from earlier tests stay invalid now, so no stale terminal is adopted.

Part of #333.

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: c523434629

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: c523434629

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 5080bd9 Oct 31, 2026
4 checks passed
@ThomasK33
ThomasK33 deleted the fix/terminal-failed-start-cleanup branch October 31, 2026 18:05
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 1, 2026
…oder#336)

## Why

The goal is to clear the follow-up bugs listed in coder#333 and coder#334, one
small reviewed PR per bug. Part of coder#334. This PR handles item 1.

The native terminal buffer has `bufhidden=hide`. On main,
`close_terminal` only closed the window and then cleared its state. On
real Neovim this had three effects:

1. A visible terminal: `:ClaudeCodeClose` closed the window, but the
Claude job kept running in a hidden buffer that the plugin no longer
tracked. The next `:ClaudeCode` started a second Claude process.
2. A hidden terminal (toggle open, toggle hidden, `:ClaudeCodeClose`):
`winid` is `nil`, so `nvim_win_close(nil, true)` raised `Invalid 'win':
Expected Lua number`.
3. The terminal is the only window: close raised `E444: Cannot close
last window`.

The unit test "should preserve process on toggle but kill on close"
passed only because the mock's `nvim_win_close` ignored a `nil` window
id.

`close_terminal` now stops the job, waits for it to exit, and then
deletes the terminal buffer. Deleting the buffer closes every window
that shows it, whether the terminal is visible or hidden. The wait
matters: deleting a terminal buffer while a write to its job is still
pending hangs Neovim (neovim/neovim#42508, open upstream).

## What changed

- `lua/claudecode/terminal/native.lua` (`close_terminal`):
- For a job the plugin started, `close` calls `jobstop` and waits up to
1 s for `TermClose`. Then it calls `nvim_buf_delete(bufnr, { force =
true })`. The wait also ends when a user's `TermClose` autocmd already
deleted the buffer, and then `close` skips the delete.
- For an adopted terminal (`find_existing_claude_terminal`, no `jobid`),
`close` only closes its window, as on main. The adoption matcher accepts
any visible terminal whose name contains `claude`, so the terminal can
be an unrelated shell.
- `tests/unit/native_terminal_toggle_spec.lua`: the mock now behaves
like Neovim in these points:
  - `nvim_win_close` raises for a `nil` or unknown window id.
- `chansend` leaves a pending write, and `nvim_buf_delete` raises while
a write is pending.
- `jobstop` queues the job's exit, and `vim.wait` runs it (pending
writes end, then `TermClose` fires).
- Global `TermClose` autocmds run too, and a buffer-local autocmd is
dropped once its buffer is deleted.
- New and rewritten tests cover close for a hidden terminal, a visible
terminal, close right after a send, a user `TermClose` autocmd that
deletes the buffer, and an adopted shell.
- `lua/claudecode/terminal/README.md`: the `native.close` side-effect
line now describes the fixed behavior.

## Scope

Only the native provider's `close`. Hide, show, toggle, and the snacks
and external providers do not change. The loose `claude` substring match
in `find_existing_claude_terminal` is older than this PR and stays as it
is.

## Blast Radius

Users of the native terminal provider who run `:ClaudeCodeClose` or call
`require("claudecode.terminal").close()`. Close now ends the Claude
session, which the test name already promised. Hide (toggle) still keeps
the process alive. A close normally takes about 2 ms (measured with a
node process). For a process that ignores SIGHUP, close blocks for 1 s,
and Neovim then kills the process itself.

Known Neovim race, not fixed here: on Neovim 0.9.5 to 0.12.6, `jobstop`
on a terminal job within a few milliseconds of `termopen` can kill
Neovim with "Caught deadly signal 'SIGHUP'". A raw script without the
plugin (`termopen` and then `jobstop` at once, 20 times per run) killed
Neovim in 3 to 5 of 15 runs on each version. With a 50 ms pause it never
did. `close` now calls `jobstop`, so a script that opens and closes in
the same tick can hit this. A person typing `:ClaudeCodeClose` cannot.
My guess at the cause, from strace: the forked child gets SIGHUP before
it resets its signal handlers, and libuv's signal pipe, shared with the
parent, passes it to Neovim. I did not verify this in Neovim's source.

## Verification

I ran a headless script (native provider, `terminal_cmd = "cat"`) with
17 checks on Neovim 0.8.3, 0.9.5, 0.10.4, 0.11.6, 0.12.3, and 0.12.6.

| Code | Result |
| --- | --- |
| main `b03b0bf` (0.12.6) | 7 of 17 pass. Visible close leaves the job
running. Hidden close and only-window close raise. |
| first fix `e93a0db` | Hangs at "send then close" on 0.8.3 and 0.12.6
(killed by timeout). |
| `55692ea` | 17 of 17 pass on all six versions. A user `TermClose`
autocmd that deletes the buffer makes close raise `Invalid buffer id`. |
| `ca70b2d` | 17 of 17 pass in 8 of 8 runs on each of the six versions.
Close with that autocmd succeeds in about 1 ms. |
| head `67a3605` (`ca70b2d` merged with main, which brings coder#339) | 17 of
17 pass in 4 of 4 runs on each of the six versions. |

The script waits 50 ms after each open because of the Neovim race above.
The checks cover close from the terminal window and from the editor
window, hidden close, close right after a send (5 bytes, and 24 KB with
submit), close then reopen, the only-window case, a second tab that
shows the terminal, an adopted `:terminal` shell (its process keeps
running), and close with no terminal.

Other runs:

- A 64 KB send to a process that ignores SIGHUP and SIGTERM, then close:
completes in about 1 s on 0.9.5 and 0.12.6, the buffer is gone, and
Neovim kills the process within 6 s.
- Raw Neovim, `chansend` and then `nvim_buf_delete`: hangs on 0.9.5 to
0.12.6. With `jobstop` and a delete after `TermClose`: completes.

Unit tests: each test commit fails against the code before it, and the
fix commits make them pass. `mise run check` (0 warnings), `mise run
test` (743 successes, 0 failures), `stylua --check`, and `prettier
--check` pass on the head commit.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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.

1 participant