carlos-algms / agentic-ui-widget-lifecycle
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.
stateDiagram-v2
[*] --> hidden
hidden --> visible: show()<br/>create fresh windows<br/>reapply window-local opts
visible --> hidden: hide()<br/>close + destroy widget windows<br/>buffers persist
state "destroy()" as destroy
visible --> destroy
hidden --> destroy
destroy --> [*]: WidgetLayout.close(win_nrs)<br/>then buffers deleted<br/>(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
showafterhidecreates fresh windows and applies every window-local
option.showon a visible widget reuses its live windows; it reopens
nothing.Before closing widget windows,
hideensures a non-widget fallback window
exists in the same tabpage; iffind_first_non_widget_windowreturns nil it
callsopen_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. SeeChatWidget:hide.A caller that reaches
hideacross an async boundary MUST pass the tabpage it
captured BEFORE the defer, ashide(keep_insert, tabpage).WinClosedfires onwin_nrs.chatand invalidates it before the scheduledhideruns, andget_visible_tab_idreads no other handle, so the derived placement is already
nil there:_ensure_fallback_windowreturns early and the close takes the
widget-only tabpage down with it. Only the tabpage IDENTITY crosses the boundary
—_ensure_fallback_windowandfind_first_non_widget_windowstill re-checknvim_tabpage_is_validon it. Regression:chat_widget.test.lua::"keeps a widget-only tab alive when the chat window closes".ChatWidget:open_editor_windowanchors on the first USABLE window inwin_nrs, preferring chat. The chat handle is already dead on theWinClosed
path above, and returning nil there is what loses the tabpage.Programmatic window closes (
hide, layout rotation) MUST wrap the close call
inChatWidget:_avoid_auto_close_cmd. The wrapper setsself._closing = true
so theWinClosedautocmd's auto-close-on-user-close branch skips the call.
Skipping the wrapper triggers a recursive close through the autocmd. Tests:chat_widget.test.luagroup"WinClosed autocmd".destroynever routes throughhide, but both shareChatWidget:_ensure_fallback_window. It calls that, thenWidgetLayout.close
on itswin_nrs, then deletes the buffers. Relative tohideit skips
hidden-float recreation, size capture, andstopinsertsuppression — a
destroyed widget is never shown again.WidgetLayout.closeskips every handle
whose tabpage is already gone, keeping this safe during a tabclose teardown on
0.11.x. SeeChatWidget:destroy.The fallback is mandatory here.
SessionRegistry.show_sessionhides
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 reachesWidgetLayout.closethere, and without the fallback that tabpage
disappears under the user. Reachable fromAgentic.destroy_sessionand from
restore's explicitDestroy current sessionchoice. 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_windowexcludes windows showing a registered widget
buffer, so once unregistered the widget's own chat window looks like a valid
fallback and gets handed back.
destroyis the one programmatic close exempt from the_avoid_auto_close_cmdrule above. It deletes theAgenticWinClosed_<chat bufnr>augroup before closing, so the only listener
that ever readself._closingis 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 = falseandbuftype = nofile. Each widget
window records its buffer invim.w[winid].agentic_bufnr, 1:1. Users cannot
swap buffers between widget windows;BufferGuardrestores them.The
WinClosedautocmd 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. TheFileList, 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, andshow_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_windowcloses 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,
henceBufHelpers.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
oncewin_nrswas 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:<C-w>w/<C-w>p,:wincmd, and:bufferskip it;nvim_list_wins()returns it but interactive navigation does not visit it.
Only code holdingwidget._hidden_chat_winidcan target it (vianvim_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".