carlos-algms / agentic-ui-message-writer
Rules for chat-buffer content and tool-call rendering. Use before editing MessageWriter, PermissionManager, tool-call block rendering, sender headers, thinking blocks, auto-scroll, folds, status rows, permission buttons, or chat- buffer tool-call tests.
Agentic UI Message Writer
This skill covers chat-buffer content state. For widget windows, layout,
fallback windows, hidden floats, and buffer redirection, seelua/agentic/ui/AGENTS.md and skill agentic-ui-widget-lifecycle.
Hard rules
wrapstays on.- Cursor positioning is
G0zb, notG$zb. Cursor sits on the trailing
""line below the last block, never inside a
tool-call block.scrolloff = 4on chat keeps room for spinner virt_lines above the cursor.Auto-scroll captures before mutation and applies after mutation in the same
tick. Novim.schedulebetween them.The
_capture_scroll/_apply_scrollpair is needed only when a mutation
changes the buffer's line count. A rewrite that keeps the line count
(repaint_status_row) skips it.- Tool-call body updates replace only the body between stable anchor pads.
Manual folds only. Never
foldexpr; read ADR 0001 before proposing foldexpr
workarounds.tracker.rendered_button_countis render-path-only state.
Tool-call layout
row 0 header rewritten on every update, NOT folded
row 1 "" top_pad fold start anchor
row 2..M-1 body replaced on every update
row M "" bottom_pad fold end anchor
row M+1..M+K permission K rows: N button rows + N empty spacer rows
row M+K+1 status row real text, outside the foldPinned by, in lua/agentic/ui/message_writer.test.lua:::"emits anchor pad lines around the body in every block" (pads) and::"writes pending status word as real text at row N" (status row).
K = 2 * Nfor N permission options.- Permission rows are outside the fold.
MessageWriter:_render_permission_sectionowns all permission rows plus the
status row.- Pads are unconditional.
Header is rewritten because providers send placeholder titles before final
titles.
Permission rows
- Buttons live one-per-row between
bottom_padand the status row. Empty spacer rows sit between buttons, with one trailing spacer above the
status row.- Digit keymaps are bound only while a block is focused.
Cycle keys and
<CR>are row-gated to button rows or the focused block's
status row.- Spacer rows fall through to default motion.
- Focus transition repaints old and new status rows.
Regression anchors:
permission_manager.test.lua::digit keymap lifecycle::"rebinds digit keymaps with new mapping after focus transition"permission_manager.test.lua::bracket cycle::"focus transition triggers exactly 2 status-row repaints"
Special write paths
Use only the normal write path outside these cases.
| Method | When to use |
|---|---|
write_structural_message | Welcome banner on session open; banner before restore |
write_restoring_message | Per-message replay during session restore |
replay_history_messages | Provider switch only; bulk repaint from in-memory history |
Outside restore/provider-switch, use
write_message_chunkorwrite_tool_call_block.replay_history_messagesdoes not re-issue ACPsend_prompt.write_restoring_messagecallswrite_message(), notwrite_message_chunk(). Each replayeduser_message_chunkbecomes its own
message; chunks are not reassembled. Pinned by, inlua/agentic/session_manager.test.lua:::"renders replay chunks as formatted messages".- Adding a bulk-write path requires a new row here and a test.
Sender classification
MessageWriter:_maybe_write_sender_header maps update.sessionUpdate to
sender. New ACP update types must be classified here or they get no header.
user_message_chunk -> user
agent_message_chunk -> agent
agent_thought_chunk -> agent
tool_call -> agent
plan -> no headerPinned by, in lua/agentic/ui/message_writer.test.lua:::"writes user header on first user_message_chunk",::"writes agent header on first agent_message_chunk",::"writes agent header before tool call block",::"skips header for plan updates". No test pins agent_thought_chunk; add one
before changing it.
Thinking blocks reuse one extmark in NS_THINKING. Any non-thought write must
clear thinking state first or the next thought extends the wrong extmark.
TodoList
TodoListownsChatWidget.buf_nrs.todos.- It opens after diagnostics in
WidgetLayout. - It is gated by
Config.windows.todos.display. - It stays hidden until the first Plan event.
- It auto-closes when all tasks complete.
- It has no keymaps.
Traps
vim.schedulebetween mutation andG0zb: redraw can run with stale topline.- Replacing the whole tool-call range: manual fold dies.
Re-rendering tool-call body after
tracker.diffexists: preview consistency
breaks. Once a diff exists, refresh only header/status.Overwriting status/button rows while permission is pending: buttons disappear
until the next focus repaint. Use_render_permission_section.
Test invariants
Each invariant has an existing regression test. Deleting one is a behavior
change.
- Fold survives close and reopen.
- Fold creation is gated by screen-row count.
- Fold counts wrapped rows, not buffer lines.
- Status row and permission rows are real text rendered per state.
- Block range extmark grows by K on permission render.
- Focus transition triggers exactly two status-row repaints.
- Digit keymap dispatches the focused block option.
- Bracket cycle wraps and no-ops when pending is empty.
Concurrent permission map preserves insertion order and supports out-of-order
resolve.- Sender headers deduplicate consecutive same-sender writes.
- Auto-scroll threshold preserves reading position and permission-row cursor.
- Thinking state clears on non-thought writes.