Goal
- Fix the race condition where tool panels append to the end instead of inline when tools complete after stream cancellation or end.
Non-Goals
- No deployment/ops changes
- No observability/metrics additions
- No new dependencies
Scope & Assumptions
In Scope:
- Fix
insert_before_stream()to correctly position tool panels even after stream ends - Track insertion point when stream ends so late-arriving tool panels insert correctly
- Handle cancel scenario where tools complete post-cancel
Out of Scope:
- Tool cancellation signal propagation (separate concern)
- Throttling/batching of tool panels (LOW priority per research)
Assumptions:
- Textual's
mount(before=widget)works correctly when widget exists - Tool panels arrive via
post_message()which is async (confirmed in research) - Single-threaded event loop; no concurrent message handling
Deliverables
- Modified
ChatContainerclass with insertion point tracking - Updated
insert_before_stream()to use tracked position - Updated
end_stream()andcancel_stream()to preserve insertion context
Readiness
- Research complete with flow analysis and root cause identified
- Code locations verified in
chat.py(lines 137-268) - No external dependencies required
Milestones
- M1: Insertion point tracking infrastructure
- M2: Fix
insert_before_stream()logic - M3: Basic test scenario (manual verification)
Work Breakdown (Tasks)
Task 1: Add insertion anchor tracking to ChatContainer
Summary: Track the last widget before which tool panels should insert, persisting after stream ends.
Files: src/tunacode/ui/widgets/chat.py
Changes:
- Add
_insertion_anchor: Widget | Noneattribute in__init__ - In
start_stream(): set_insertion_anchor = None(panels go before stream widget) - In
end_stream(): capture the finalized_current_streamwidget as_insertion_anchorBEFORE setting_current_stream = None - In
cancel_stream(): capture widget reference before removal, set_insertion_anchorto next sibling if exists
Acceptance Test: After calling end_stream(), _insertion_anchor holds reference to the finalized message widget.
Dependencies: None Milestone: M1
Task 2: Update insert_before_stream() to use insertion anchor
Summary: Modify logic to insert before anchor when stream is None but anchor exists.
Files: src/tunacode/ui/widgets/chat.py
Changes:
def insert_before_stream(self, renderable: RenderableType) -> None:
widget = Static(renderable)
widget.add_class("chat-message")
if self._current_stream is not None:
# Active stream: insert before streaming widget
self.mount(widget, before=self._current_stream)
elif self._insertion_anchor is not None:
# Stream ended: insert before the finalized message
self.mount(widget, before=self._insertion_anchor)
else:
# No context: append
self.mount(widget)
if self._auto_scroll:
self.scroll_end(animate=False)
Acceptance Test: Tool panel arriving after end_stream() appears before the finalized agent response, not at bottom.
Dependencies: Task 1 Milestone: M2
Task 3: Clear insertion anchor on new request
Summary: Reset anchor when starting a new stream to prevent stale positioning.
Files: src/tunacode/ui/widgets/chat.py
Changes:
- In
start_stream(): set_insertion_anchor = Noneat the start - In
clear(): set_insertion_anchor = None
Acceptance Test: New request does not use stale anchor from previous request.
Dependencies: Task 1 Milestone: M2
Task 4: Handle cancel scenario insertion anchor
Summary: On cancel, preserve insertion context for late-arriving tool panels.
Files: src/tunacode/ui/widgets/chat.py
Changes:
- In
cancel_stream(): before removing_current_stream, find its next sibling - Set
_insertion_anchorto next sibling (or None if last child) - Alternative: Keep
_current_streamhidden instead of removing? (research: simpler to just track position)
Design Decision: Track position via index, not sibling reference. Siblings can be fragile.
Better approach:
def cancel_stream(self) -> None:
if self._current_stream is not None:
# Find the index of the stream widget
children = list(self.children)
try:
idx = children.index(self._current_stream)
# Anchor is widget that will be at this position after removal
if idx > 0:
self._insertion_anchor = children[idx - 1]
except ValueError:
pass
self._current_stream.remove()
self._current_stream = None
self.remove_class("streaming")
Wait - this anchors to widget BEFORE, but we want to insert AFTER it. Let me reconsider.
Revised approach: Use after= parameter for mount instead:
def cancel_stream(self) -> None:
if self._current_stream is not None:
children = list(self.children)
try:
idx = children.index(self._current_stream)
if idx > 0:
self._insertion_after_anchor = children[idx - 1]
except ValueError:
pass
self._current_stream.remove()
self._current_stream = None
self.remove_class("streaming")
Then in insert_before_stream():
elif self._insertion_after_anchor is not None:
self.mount(widget, after=self._insertion_after_anchor)
Acceptance Test: Tool panel arriving after cancel_stream() appears at the position where streaming was occurring.
Dependencies: Task 2 Milestone: M2
Task 5: Manual verification
Summary: Verify fix with real tool calls in TUI.
Files: None (manual testing)
Steps:
- Start tunacode TUI
- Run request that triggers tool (e.g., "read file X")
- Wait for tool panel to appear inline during streaming
- Cancel mid-stream (Ctrl+C or escape)
- Observe: any late tool panels should NOT appear at bottom
Acceptance Test: Visual confirmation that tool panels maintain correct ordering in all scenarios.
Dependencies: Tasks 1-4 Milestone: M3
Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
Textual after= behavior differs from expected |
Low | Medium | Verify Textual docs; test in isolation |
| Removed widget reference causes issues | Low | Low | Clear anchor when widget removed by other means |
| Stale anchor persists across sessions | Low | Low | Clear in clear() and start_stream() |
Test Strategy
- Task 2 acceptance: Create unit test that mounts ChatContainer, starts stream, ends stream, then calls
insert_before_stream()and verifies widget order.
Single test file: tests/ui/test_chat_container.py
References
- Research doc:
memory-bank/research/2026-01-27_insert-before-stream-investigation.md - Code:
src/tunacode/ui/widgets/chat.py:244-261(current implementation) - Textual mount docs: https://textual.textualize.io/api/widget/#textual.widget.Widget.mount
Final Gate
- Plan path:
memory-bank/plan/2026-01-27_17-41-42_insert-before-stream-fix.md - Milestone count: 3
- Tasks ready for coding: 5
Next command: /context-engineer:execute "memory-bank/plan/2026-01-27_17-41-42_insert-before-stream-fix.md"