Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/deep_code_agent/tui/bridge/agent_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,11 @@ def handle_event() -> None:
if widget is self._active_tool_widget:
self._active_tool_widget = None

elif event.type == EventType.TODOS_UPDATE:
todos = event.data if isinstance(event.data, list) else []
if hasattr(chat_log, "upsert_todos_card"):
chat_log.upsert_todos_card(todos)

elif event.type == EventType.HITL_INTERRUPT:
status_bar.set_waiting_approval()
interrupt_data = event.data
Expand Down
80 changes: 80 additions & 0 deletions src/deep_code_agent/tui/bridge/stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class EventType(Enum):
TOOL_SUCCESS = auto() # Tool execution succeeded
TOOL_ERROR = auto() # Tool execution failed
TOOL_RESULT = auto() # Tool execution result (legacy)
TODOS_UPDATE = auto() # Agent todo list was created or updated
HITL_INTERRUPT = auto() # Human-in-the-loop approval needed
ERROR = auto() # Error occurred
DONE = auto() # Stream complete
Expand Down Expand Up @@ -49,6 +50,8 @@ class StreamHandler:
handle_event(event)
"""

TODO_STATUSES = {"pending", "in_progress", "completed", "failed"}

def __init__(self, agent, config: dict):
"""Initialize stream handler.

Expand All @@ -62,6 +65,79 @@ def __init__(self, agent, config: dict):
self._interrupted = False
self._seen_tool_call_ids: set[str] = set()

def _normalize_todo_item(self, item: Any) -> dict[str, str] | None:
"""Normalize one todo item from dict/object forms.

LangChain's TodoListMiddleware currently emits todo objects with
``content`` and ``status`` fields. Keep the UI tolerant of dict,
pydantic-like, and light object forms so stream chunk shape changes do
not break the TUI.
"""
if hasattr(item, "model_dump"):
try:
item = item.model_dump()
except Exception:
pass

if isinstance(item, dict):
content = item.get("content")
status = item.get("status")
else:
content = getattr(item, "content", None)
status = getattr(item, "status", None)

if content is None or status is None:
return None

content_text = str(content).strip()
status_text = str(status).strip()
if not content_text or status_text not in self.TODO_STATUSES:
return None

return {"content": content_text, "status": status_text}

def _normalize_todos(self, todos: Any) -> list[dict[str, str]]:
"""Normalize a todo list payload, skipping malformed entries."""
if not isinstance(todos, list):
return []

normalized: list[dict[str, str]] = []
for item in todos:
todo = self._normalize_todo_item(item)
if todo is not None:
normalized.append(todo)
return normalized

def _find_todos_payload(self, chunk: Any, *, max_depth: int = 4) -> list[dict[str, str]]:
"""Find and normalize the first valid ``todos`` payload in an update chunk."""
if max_depth < 0:
return []

if hasattr(chunk, "model_dump"):
try:
chunk = chunk.model_dump()
except Exception:
pass

if isinstance(chunk, dict):
if "todos" in chunk:
todos = self._normalize_todos(chunk.get("todos"))
if todos:
return todos

for value in chunk.values():
todos = self._find_todos_payload(value, max_depth=max_depth - 1)
if todos:
return todos

elif isinstance(chunk, list):
for item in chunk:
todos = self._find_todos_payload(item, max_depth=max_depth - 1)
if todos:
return todos

return []

async def _process_stream(self, stream, include_tool_calls: bool = True) -> AsyncIterator[AgentEvent]:
"""Process the agent stream and yield events.

Expand Down Expand Up @@ -182,6 +258,10 @@ async def _process_stream(self, stream, include_tool_calls: bool = True) -> Asyn
yield AgentEvent(type=EventType.HITL_INTERRUPT, data=interrupt_data)
return

todos = self._find_todos_payload(chunk)
if todos:
yield AgentEvent(type=EventType.TODOS_UPDATE, data=todos)
Comment on lines +261 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit todo updates even when the list is emptied

TodoListMiddleware updates replace the entire todo list, so an empty todos array is a valid state transition (clear all tasks). The truthiness check here drops that update, which means the TUI never receives a TODOS_UPDATE event when tasks are cleared and can keep showing stale items from earlier steps. This makes the progress card inaccurate after any write_todos([])-style update; emit the event whenever a todos payload is present, not only when it is non-empty.

Useful? React with 👍 / 👎.


async def process(self, state: dict) -> AsyncIterator[AgentEvent]:
"""Process a user request and yield events.

Expand Down
2 changes: 2 additions & 0 deletions src/deep_code_agent/tui/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from deep_code_agent.tui.widgets.selectable_option import SelectableOption
from deep_code_agent.tui.widgets.side_panel import SidePanel
from deep_code_agent.tui.widgets.status_bar import StatusBar
from deep_code_agent.tui.widgets.todos_progress_card import TodosProgressCard
from deep_code_agent.tui.widgets.tool_call_view import ToolCallView

__all__ = [
Expand All @@ -15,5 +16,6 @@
"SelectableOption",
"SidePanel",
"StatusBar",
"TodosProgressCard",
"ToolCallView",
]
56 changes: 51 additions & 5 deletions src/deep_code_agent/tui/widgets/chat_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from textual.reactive import reactive

from deep_code_agent.tui.widgets.message_bubble import MessageBubble
from deep_code_agent.tui.widgets.todos_progress_card import TodosProgressCard


class ChatLog(VerticalScroll):
Expand Down Expand Up @@ -35,11 +36,22 @@ class ChatLog(VerticalScroll):
# Reactive state for tracking if auto-scroll is enabled
auto_scroll = reactive(True)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._todos_card: TodosProgressCard | None = None

def compose(self):
"""Compose the chat log (empty initially)."""
# Messages will be added dynamically
return []

def _mount_above_todos_card(self, widget) -> None:
"""Mount chat content above the pinned todos card when it exists."""
if self._todos_card is not None and self._todos_card in self.children:
self.mount(widget, before=self._todos_card)
return
self.mount(widget)

def add_user_message(self, content: str) -> MessageBubble:
"""Add a user message to the chat log.

Expand All @@ -50,7 +62,7 @@ def add_user_message(self, content: str) -> MessageBubble:
The created MessageBubble widget
"""
bubble = MessageBubble(content, role="user")
self.mount(bubble)
self._mount_above_todos_card(bubble)
self._scroll_to_bottom()
return bubble

Expand All @@ -64,7 +76,7 @@ def add_agent_message(self, content: str) -> MessageBubble:
The created MessageBubble widget
"""
bubble = MessageBubble(content, role="agent")
self.mount(bubble)
self._mount_above_todos_card(bubble)
self._scroll_to_bottom()
return bubble

Expand All @@ -78,7 +90,7 @@ def add_system_message(self, content: str) -> MessageBubble:
The created MessageBubble widget
"""
bubble = MessageBubble(content, role="system")
self.mount(bubble)
self._mount_above_todos_card(bubble)
self._scroll_to_bottom()
return bubble

Expand All @@ -92,7 +104,7 @@ def add_tool_call(self, tool_name: str, args: dict) -> None:
# Format the tool call nicely
content = f"🔧 Tool call: {tool_name}"
bubble = MessageBubble(content, role="system")
self.mount(bubble)
self._mount_above_todos_card(bubble)
self._scroll_to_bottom()

def add_tool_call_widget(
Expand Down Expand Up @@ -121,15 +133,49 @@ def add_tool_call_widget(
status=status,
result=result
)
self.mount(widget)
self._mount_above_todos_card(widget)
self._scroll_to_bottom()
return widget

def upsert_todos_card(self, todos: list[dict[str, str]]) -> TodosProgressCard:
"""Create or update the singleton todos progress card.

The card is moved to the bottom on every update so the latest task
status stays visible in the chat stream.
"""
if self._todos_card is None:
self._todos_card = TodosProgressCard(todos)
self.mount(self._todos_card)
self._scroll_to_bottom()
return self._todos_card

expanded = self._todos_card.expanded
self._todos_card.update_todos(todos)
self._todos_card.expanded = expanded

if self.children and self.children[-1] is not self._todos_card:
try:
self.move_child(self._todos_card, after=len(self.children) - 1)
except Exception:
# Textual versions vary in move/remount behavior. If moving an
# existing widget fails, recreate the card while preserving the
# user's expanded/collapsed preference.
try:
self._todos_card.remove()
except Exception:
pass
self._todos_card = TodosProgressCard(todos, expanded=expanded)
self.mount(self._todos_card)

self._scroll_to_bottom()
return self._todos_card

def clear_messages(self) -> None:
"""Clear all messages from the chat log."""
# Remove all children (MessageBubble widgets)
for child in list(self.children):
child.remove()
self._todos_card = None

def _scroll_to_bottom(self) -> None:
"""Scroll to the bottom of the chat log."""
Expand Down
8 changes: 7 additions & 1 deletion src/deep_code_agent/tui/widgets/message_bubble.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,11 @@ def compose(self):
def update_content(self, new_content: str) -> None:
"""Update the bubble content (useful for streaming)."""
self.content = new_content
content_widget = self.query_one(".message-text", Static)
try:
content_widget = self.query_one(".message-text", Static)
except Exception:
# The bubble may have been mounted but not composed yet when
# streamed chunks complete in the same event-loop turn. Storing
# content is enough; compose() will render the latest value.
return
content_widget.update(new_content)
Loading
Loading