---
name: agentic-ui-widget-lifecycle
description: >
Rules for ChatWidget window lifecycle. Use before editing ChatWidget
show/hide/destroy, WidgetLayout, WidgetRegistry, WindowDecoration,
BufferGuard, SessionRegistry.show_session, the hidden chat float, fallback
windows, or any programmatic window close.
---
# ChatWidget lifecycle
Widget windows are disposable. Buffers persist; windows do not.
## State machine
Each transition cites the test that pins it. A transition without a test is a
gap: add the test or delete the transition.
```mermaid
stateDiagram-v2
[*] --> hidden
hidden --> visible: show()
create fresh windows
reapply window-local opts
visible --> hidden: hide()
close + destroy widget windows
buffers persist
state "destroy()" as destroy
visible --> destroy
hidden --> destroy
destroy --> [*]: WidgetLayout.close(win_nrs)
then buffers deleted
(close skips handles whose tabpage is gone)
```
Pinned by, in `lua/agentic/ui/chat_widget.test.lua`:
- `hidden -> visible`: `::"show() after hide() creates new windows"`,
`::"show() is idempotent when called multiple times"`
- `visible -> hidden`: `::"hide() closes all windows and preserves buffers"`,
`::"hide() is safe when called multiple times"`
- `hidden -> destroy`: `::"deletes the buffers of a hidden widget without raising"`
- `visible -> destroy`:
`::"keeps the tabpage alive when the widget holds its only windows"`
- hidden float on `destroy`: `::"tears down the hidden chat window on destroy"`
## Rules
- `show` after `hide` creates fresh windows and applies every window-local
option. `show` on a visible widget reuses its live windows; it reopens
nothing.
- Before closing widget windows, `hide` ensures a non-widget fallback window
exists in the same tabpage; if `find_first_non_widget_window` returns nil it
calls `open_editor_window`. Skipping this destroys the user's tabpage: closing
the last window of a non-current tabpage closes that tabpage silently, and E444
(cannot close last window) only fires when it is also the last tabpage. See
`ChatWidget:hide`.
- A caller that reaches `hide` across an async boundary MUST pass the tabpage it
captured BEFORE the defer, as `hide(keep_insert, tabpage)`. `WinClosed` fires on
`win_nrs.chat` and invalidates it before the scheduled `hide` runs, and
`get_visible_tab_id` reads no other handle, so the derived placement is already
nil there: `_ensure_fallback_window` returns early and the close takes the
widget-only tabpage down with it. Only the tabpage IDENTITY crosses the boundary
— `_ensure_fallback_window` and `find_first_non_widget_window` still re-check
`nvim_tabpage_is_valid` on it. Regression:
`chat_widget.test.lua::"keeps a widget-only tab alive when the chat window closes"`.
- `ChatWidget:open_editor_window` anchors on the first USABLE window in
`win_nrs`, preferring chat. The chat handle is already dead on the `WinClosed`
path above, and returning nil there is what loses the tabpage.
- Programmatic window closes (`hide`, layout rotation) MUST wrap the close call
in `ChatWidget:_avoid_auto_close_cmd`. The wrapper sets `self._closing = true`
so the `WinClosed` autocmd's auto-close-on-user-close branch skips the call.
Skipping the wrapper triggers a recursive close through the autocmd. Tests:
`chat_widget.test.lua` group `"WinClosed autocmd"`.
- `destroy` never routes through `hide`, but both share
`ChatWidget:_ensure_fallback_window`. It calls that, then `WidgetLayout.close`
on its `win_nrs`, then deletes the buffers. Relative to `hide` it skips
hidden-float recreation, size capture, and `stopinsert` suppression — a
destroyed widget is never shown again. `WidgetLayout.close` skips every handle
whose tabpage is already gone, keeping this safe during a tabclose teardown on
0.11.x. See `ChatWidget:destroy`.
- The fallback is mandatory here. `SessionRegistry.show_session` hides
non-target widgets in the current tabpage and hides the target at its previous
placement, so an outgoing session in another tabpage stays visible. Destroying
it reaches `WidgetLayout.close` there, and without the fallback that tabpage
disappears under the user. Reachable from `Agentic.destroy_session` and from
restore's explicit `Destroy current session` choice. Regression:
`chat_widget.test.lua::"keeps the tabpage alive when the widget holds its only windows"`.
- **Ordering: the fallback MUST run before `WidgetRegistry.unregister`.**
`find_first_non_widget_window` excludes windows showing a registered widget
buffer, so once unregistered the widget's own chat window looks like a valid
fallback and gets handed back.
- `destroy` is the one programmatic close exempt from the
`_avoid_auto_close_cmd` rule above. It deletes the
`AgenticWinClosed_` augroup before closing, so the only listener
that ever read `self._closing` is gone by then; setting the flag would guard
nothing. Any new close path that runs while that augroup still exists MUST
wrap.
## Widget buffers and windows
- Widget buffers are `buflisted = false` and `buftype = nofile`. Each widget
window records its buffer in `vim.w[winid].agentic_bufnr`, 1:1. Users cannot
swap buffers between widget windows; `BufferGuard` restores them.
- The `WinClosed` autocmd hides the WHOLE widget when the user closes any one
widget window. The widget is one unit.
## Background sessions
A **background session** must never be surfaced by a content callback. The
`FileList`, `CodeSelection`, `DiagnosticsList` and `TodoList` `on_change`
handlers call `ChatWidget:rerender`, which no-ops when `get_visible_tab_id()` is
nil. Calling `show` directly there put a second widget in the current tabpage —
a `plan` update from a hidden session was enough, with no user action at all.
The update survives: panel buffers are written before `on_change` fires, and
`show_layout` decides each panel window from buffer emptiness at show time.
Regression:
`tests/integration/test_multi_session.lua::"keeps a hidden session hidden when its file list changes"`.
- **An EMPTY panel is the same path and MUST obey the same two rules.** The
existing regression covers a non-empty panel only, and the empty case takes a
different branch: `open_or_resize_dynamic_window` closes the panel window
instead of opening one. That close runs asynchronously, so the cached handle
can belong to a tabpage the user has since closed — stale-valid on 0.11.x,
hence `BufHelpers.is_win_usable`, not bare validity — and closing it MUST NOT
move the cursor out of the tab the user is looking at. Regression:
`lua/agentic/ui/widget_layout.test.lua::"clears an empty panel whose tabpage is gone"`.
- A cached panel handle from ANOTHER tabpage MUST NOT be reused, and MUST be
closed when the panel is reopened. Chat and input enforced this; dynamic
panels did not, so a widget that moved tabpages left its panels behind and
split one widget's topology across two tabs, the abandoned window untracked
once `win_nrs` was repointed. Regression:
`lua/agentic/ui/widget_layout.test.lua::"creates a fresh chat window when the cached one is in another tab"`.
## Hidden chat float
A hidden chat floating window keeps the chat buffer attached while the widget
is hidden, so manual folds can be applied while closed. See ADR 0001. Tests:
`chat_widget.test.lua` group `"hidden chat window lifecycle"`.
- Opened with `hide = true` + `focusable = false` + `noautocmd = true`. The user
cannot reach it: `w`/`p`, `:wincmd`, and `:buffer` skip it;
`nvim_list_wins()` returns it but interactive navigation does not visit it.
Only code holding `widget._hidden_chat_winid` can target it (via
`nvim_set_current_win`/`nvim_win_set_buf`). Treat it as an internal handle,
not a window the user might be sitting in. Do NOT add keymaps, buffer-local
autocmds expecting user focus, or any UX that assumes the user can act inside
it.
- Reopening the hidden chat float without closing the previous one overwrites
the stored winid and leaks the prior window. Regression:
`chat_widget.test.lua::"does not leak a hidden float across hide() calls"`.