Goal
Integrate tinyagent thinking_delta stream events into TunaCode Textual UI so reasoning content can be shown in a dedicated muted panel when the user enables it.
Final Decisions (No Open Questions)
- UI gate: reasoning display is controlled by
session.show_thoughts(defaultFalse). - Command location:
/thoughtsis implemented insrc/tunacode/ui/commands/(not inapp.pyinline parser). - Display behavior:
- During request: show reasoning in
#thinking-outputwhen enabled. - After request completes or is cancelled: clear/hide the thinking panel.
- During request: show reasoning in
- Toggle behavior mid-stream:
- Toggle OFF: hide panel immediately, keep in-memory buffer for this request.
- Toggle ON: render current in-memory buffer immediately.
- Scope boundary: this iteration is TUI display only. We do not change session persistence schema or sanitize stored tinyagent message payloads.
- Final assistant panel content: UI final response renderer must use text-only assistant blocks (ignore
thinkingblocks) to keep reasoning separate from final answer.
Preconditions
- UI design rule compliance (mandatory): read and apply
.claude/skills/neXTSTEP-ui/SKILL.mdbefore UI edits. - Verify tinyagent in environment exposes thinking stream events:
uv run python - <<'PY'
from tinyagent.agent_types import STREAM_UPDATE_EVENTS
required = {"thinking_start", "thinking_delta", "thinking_end"}
missing = required - set(STREAM_UPDATE_EVENTS)
if missing:
raise SystemExit(f"Missing thinking stream events: {sorted(missing)}")
print("thinking stream events present")
PY
Files To Change
src/tunacode/core/agents/main.pysrc/tunacode/ui/renderers/thinking.py(new)src/tunacode/ui/app.pysrc/tunacode/ui/styles/layout.tcsssrc/tunacode/ui/commands/thoughts.py(new)src/tunacode/ui/commands/__init__.pytests/unit/core/test_thinking_stream_routing.py(new)tests/unit/ui/test_thinking_renderer.py(new)tests/unit/ui/test_app_latest_response_text.py(new)
Milestones
| ID | Milestone | Outcome |
|---|---|---|
| M1 | Core stream routing | thinking_delta routed to callback independently of text callback |
| M2 | UI rendering + widget | Thinking panel exists, styled, throttled, bounded |
| M3 | Command wiring | /thoughts toggles behavior through command system |
| M4 | Validation | Unit tests + manual reasoning-model verification pass |
Task Breakdown
Task 1 (M1): Add thinking callback plumbing in core orchestrator
File: src/tunacode/core/agents/main.py
Changes:
- In
RequestOrchestrator.__init__, add parameter:thinking_callback: StreamingCallback | None = None
- Store it as
self.thinking_callback. - In top-level
process_request(...), add same optional parameter and pass it intoRequestOrchestrator(...).
Important: use existing type alias StreamingCallback (there is no StreamCallback type).
Acceptance:
uv run ruff check src/tunacode/core/agents/main.py
Task 2 (M1): Route thinking_delta in _handle_message_update
File: src/tunacode/core/agents/main.py
Current blindspot to remove: method currently returns immediately when streaming_callback is None, which would also drop thinking events.
Required logic:
assistant_event = getattr(event, "assistant_message_event", None)
if not isinstance(assistant_event, dict):
return
ev_type = assistant_event.get("type")
delta = assistant_event.get("delta")
if not isinstance(delta, str) or not delta:
return
if ev_type == "text_delta":
if self.streaming_callback is None:
return
await self.streaming_callback(delta)
self.state_manager.session._debug_raw_stream_accum += delta
return
if ev_type == "thinking_delta":
if self.thinking_callback is None:
return
await self.thinking_callback(delta)
return
Unhandled event types should return cleanly.
Acceptance:
- Text and thinking routing behavior verified by new unit test file in Task 7.
Task 3 (M2): Add dedicated thinking renderer (muted, truncated)
File: src/tunacode/ui/renderers/thinking.py (new)
Implement:
DEFAULT_THINKING_MAX_LINES: int = 10def render_thinking(content: str, max_lines: int = DEFAULT_THINKING_MAX_LINES) -> Text:- Render last
max_lineslines only. - Prepend muted truncation marker when clipping occurs.
- Use
STYLE_MUTEDfromtunacode.ui.styles.
Do not import from non-existent tunacode.ui.renderers.constants.
Acceptance:
uv run ruff check src/tunacode/ui/renderers/thinking.py- Unit tests in Task 8.
Task 4 (M2): Wire thinking state + widget in Textual app
Files:
src/tunacode/ui/app.pysrc/tunacode/ui/styles/layout.tcss
App changes:
- Add
self.thinking_output: Staticand compose widget:self.thinking_output = Static("", id="thinking-output")- yield it between
streaming_outputandeditor.
- Add state constants/fields (symbolic constants, no magic literals):
THINKING_BUFFER_CHAR_LIMIT: int = 20000- reuse
STREAM_THROTTLE_MSfor thinking updates (or defineTHINKING_THROTTLE_MSexplicitly equal). _current_thinking_text: str = ""_last_thinking_update: float = 0.0
- Add methods:
_hide_thinking_output()_clear_thinking_state()(buffer + timer + hide)_refresh_thinking_output(force: bool = False)async _thinking_callback(delta: str)
_thinking_callbackrules:- Append delta to buffer.
- Enforce buffer cap by trimming oldest chars.
- If
show_thoughtsis false: return after buffering. - Throttle updates to avoid UI churn.
- In
_process_request(...):- call
_clear_thinking_state()before request starts. - call
_clear_thinking_state()infinally.
- call
- Pass callback to process request call:
thinking_callback=self._thinking_callback
- Update
_get_latest_response_text()to extract only assistanttextblocks (ignorethinkingblocks).
Style changes (layout.tcss):
Add #thinking-output style similar to #streaming-output, but muted look and hidden by default:
display: none- subdued outline/color
#thinking-output.active { display: block; }
Acceptance:
uv run ruff check src/tunacode/ui/app.py- App starts without Textual CSS parse errors.
Task 5 (M3): Add /thoughts command via command registry
Files:
src/tunacode/ui/commands/thoughts.py(new)src/tunacode/ui/commands/__init__.py
Implement command class:
name = "thoughts"description = "Toggle reasoning/thinking panel"- Toggles
app.state_manager.session.show_thoughts. - If enabled: call
app._refresh_thinking_output(force=True). - If disabled: call
app._hide_thinking_output(). - Notify user with enabled/disabled status.
Important: do not add elif command == ... in app.py.
Acceptance:
uv run pytest tests/unit/ui/test_command_contracts.py -q
Task 6 (M4): Manual verification flow
Run:
uv run tunacode
Checklist:
- Select a reasoning-capable model.
- Run
/thoughts→ notification says enabled. - Send prompt requiring reasoning.
- Confirm muted thinking panel appears and streams.
- Run
/thoughtsduring streaming:- panel hides immediately.
- Run
/thoughtsagain before request ends:- panel reappears with buffered reasoning.
- Request completes:
- thinking panel clears/hides.
- final assistant panel shows only final answer text (no reasoning text).
Task 7 (M4): Add core routing unit tests
File: tests/unit/core/test_thinking_stream_routing.py (new)
Cases:
text_deltacalls streaming callback and appends debug accumulator.thinking_deltacalls thinking callback even when streaming callback isNone.- Non-delta / wrong types are ignored.
Acceptance:
uv run pytest tests/unit/core/test_thinking_stream_routing.py -q
Task 8 (M4): Add renderer unit tests
File: tests/unit/ui/test_thinking_renderer.py (new)
Cases:
- Short input returns unchanged text.
- Long input truncates to last N lines with hidden-lines marker.
- Empty/whitespace input renders safely.
Acceptance:
uv run pytest tests/unit/ui/test_thinking_renderer.py -q
Task 9 (M4): Add response extraction test (text-only)
File: tests/unit/ui/test_app_latest_response_text.py (new)
Case: assistant content containing both thinking and text returns only text via _get_latest_response_text().
Acceptance:
uv run pytest tests/unit/ui/test_app_latest_response_text.py -q
Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Thinking stream floods UI updates | UI lag | Shared throttling + bounded buffer + line truncation |
| Command added in wrong place | /thoughts unreachable |
Enforce command-module implementation + contract test |
| Final response still includes reasoning | UX/security confusion | Explicit text-only extraction in _get_latest_response_text() + unit test |
| tinyagent event contract differs | Feature no-op | preflight event-contract check before coding |
Verification Gates
Run in this order:
uv run ruff check src/tunacode/core/agents/main.py src/tunacode/ui/app.py src/tunacode/ui/commands src/tunacode/ui/renderers/thinking.pyuv run pytest tests/unit/core/test_thinking_stream_routing.py tests/unit/ui/test_thinking_renderer.py tests/unit/ui/test_app_latest_response_text.py tests/unit/ui/test_command_contracts.py -quv run ruff check --fix .uv run pytest
Handoff Output (for junior)
When complete, junior should provide:
- PR with only files listed in Files To Change.
- Paste outputs of all four Verification Gates.
- 1 short GIF/screenshot set showing
/thoughtson/off behavior during a live request.
References
- Research:
memory-bank/research/2026-02-17_thinking-content-integration.md - Stream routing:
src/tunacode/core/agents/main.py - Command registry:
src/tunacode/ui/commands/__init__.py - UI app:
src/tunacode/ui/app.py - Layout styles:
src/tunacode/ui/styles/layout.tcss
Final Gate
Plan is now explicit enough for junior execution without additional clarification.
Next command:
/context-engineer:execute "memory-bank/plan/2026-02-17_thinking-content-integration.md"