Files
deepagent/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/messages.py
HyunjunJeon af5fbfabec 문서 추가: Context Engineering 문서 추가 및 deepagents_sourcecode 한국어 번역
- Context_Engineering.md: 에이전트 컨텍스트 엔지니어링 개념 정리 문서 추가
- Context_Engineering_Research.ipynb: 연구 노트북 업데이트
- deepagents_sourcecode/: docstring과 주석을 한국어로 번역
2026-01-11 17:55:52 +09:00

511 lines
14 KiB
Python

"""deepagents-cli에서 메시지(대화/툴 호출 등)를 표시하는 위젯 모음입니다.
Message widgets for deepagents-cli.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from textual.containers import Vertical
from textual.css.query import NoMatches
from textual.widgets import Markdown, Static
from deepagents_cli.ui import format_tool_display
from deepagents_cli.widgets.diff import format_diff_textual
if TYPE_CHECKING:
from textual.app import ComposeResult
from textual.widgets._markdown import MarkdownStream
# Maximum number of tool arguments to display inline
_MAX_INLINE_ARGS = 3
class UserMessage(Static):
"""Widget displaying a user message."""
DEFAULT_CSS = """
UserMessage {
height: auto;
padding: 0 1;
margin: 1 0;
background: $surface;
border-left: thick $primary;
}
UserMessage .user-prefix {
color: $primary;
text-style: bold;
}
UserMessage .user-content {
margin-left: 1;
}
"""
def __init__(self, content: str, **kwargs: Any) -> None:
"""Initialize a user message.
Args:
content: The message content
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
self._content = content
def compose(self) -> ComposeResult:
"""Compose the user message layout."""
yield Static("[bold cyan]>[/bold cyan] " + self._content)
class AssistantMessage(Vertical):
"""Widget displaying an assistant message with markdown support.
Uses MarkdownStream for smoother streaming instead of re-rendering
the full content on each update.
"""
DEFAULT_CSS = """
AssistantMessage {
height: auto;
padding: 0 1;
margin: 1 0;
}
AssistantMessage Markdown {
padding: 0;
margin: 0;
}
"""
def __init__(self, content: str = "", **kwargs: Any) -> None:
"""Initialize an assistant message.
Args:
content: Initial markdown content
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
self._content = content
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
def compose(self) -> ComposeResult:
"""Compose the assistant message layout."""
yield Markdown("", id="assistant-content")
def on_mount(self) -> None:
"""Store reference to markdown widget."""
self._markdown = self.query_one("#assistant-content", Markdown)
def _get_markdown(self) -> Markdown:
"""Get the markdown widget, querying if not cached."""
if self._markdown is None:
self._markdown = self.query_one("#assistant-content", Markdown)
return self._markdown
def _ensure_stream(self) -> MarkdownStream:
"""Ensure the markdown stream is initialized."""
if self._stream is None:
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
async def append_content(self, text: str) -> None:
"""Append content to the message (for streaming).
Uses MarkdownStream for smoother rendering instead of re-rendering
the full content on each chunk.
Args:
text: Text to append
"""
if not text:
return
self._content += text
stream = self._ensure_stream()
await stream.write(text)
async def write_initial_content(self) -> None:
"""Write initial content if provided at construction time."""
if self._content:
stream = self._ensure_stream()
await stream.write(self._content)
async def stop_stream(self) -> None:
"""Stop the streaming and finalize the content."""
if self._stream is not None:
await self._stream.stop()
self._stream = None
async def set_content(self, content: str) -> None:
"""Set the full message content.
This stops any active stream and sets content directly.
Args:
content: The markdown content to display
"""
await self.stop_stream()
self._content = content
if self._markdown:
await self._markdown.update(content)
class ToolCallMessage(Vertical):
"""Widget displaying a tool call with collapsible output.
Tool outputs are shown as a 3-line preview by default.
Press Ctrl+O to expand/collapse the full output.
"""
DEFAULT_CSS = """
ToolCallMessage {
height: auto;
padding: 0 1;
margin: 1 0;
background: $surface;
border-left: thick $secondary;
}
ToolCallMessage .tool-header {
color: $secondary;
text-style: bold;
}
ToolCallMessage .tool-args {
color: $text-muted;
margin-left: 2;
}
ToolCallMessage .tool-status {
margin-left: 2;
}
ToolCallMessage .tool-status.pending {
color: $warning;
}
ToolCallMessage .tool-status.success {
color: $success;
}
ToolCallMessage .tool-status.error {
color: $error;
}
ToolCallMessage .tool-status.rejected {
color: $warning;
}
ToolCallMessage .tool-output {
margin-left: 2;
margin-top: 1;
padding: 1;
background: $surface-darken-1;
color: $text-muted;
max-height: 20;
overflow-y: auto;
}
ToolCallMessage .tool-output-preview {
margin-left: 2;
color: $text-muted;
}
ToolCallMessage .tool-output-hint {
margin-left: 2;
color: $primary;
text-style: italic;
}
"""
# Max lines/chars to show in preview mode
_PREVIEW_LINES = 3
_PREVIEW_CHARS = 200
def __init__(
self,
tool_name: str,
args: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize a tool call message.
Args:
tool_name: Name of the tool being called
args: Tool arguments (optional)
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
self._tool_name = tool_name
self._args = args or {}
self._status = "pending"
self._output: str = ""
self._expanded: bool = False
def compose(self) -> ComposeResult:
"""Compose the tool call message layout."""
tool_label = format_tool_display(self._tool_name, self._args)
yield Static(
f"[bold yellow]Tool:[/bold yellow] {tool_label}",
classes="tool-header",
)
args = self._filtered_args()
if args:
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:_MAX_INLINE_ARGS])
if len(args) > _MAX_INLINE_ARGS:
args_str += ", ..."
yield Static(f"({args_str})", classes="tool-args")
yield Static(
"[yellow]Pending...[/yellow]",
classes="tool-status pending",
id="status",
)
# Output area - hidden initially, shown when output is set
yield Static("", classes="tool-output-preview", id="output-preview")
yield Static("", classes="tool-output-hint", id="output-hint")
yield Static("", classes="tool-output", id="output-full")
def on_mount(self) -> None:
"""Hide output areas initially."""
try:
self.query_one("#output-preview").display = False
self.query_one("#output-hint").display = False
self.query_one("#output-full").display = False
except NoMatches:
pass
def set_success(self, result: str = "") -> None:
"""Mark the tool call as successful.
Args:
result: Tool output/result to display
"""
self._status = "success"
self._output = result
try:
status = self.query_one("#status", Static)
status.remove_class("pending", "error")
status.add_class("success")
status.update("[green]✓ Success[/green]")
except NoMatches:
pass
self._update_output_display()
def set_error(self, error: str) -> None:
"""Mark the tool call as failed.
Args:
error: Error message
"""
self._status = "error"
self._output = error
try:
status = self.query_one("#status", Static)
status.remove_class("pending", "success")
status.add_class("error")
status.update("[red]✗ Error[/red]")
except NoMatches:
pass
# Always show full error - errors should be visible
self._expanded = True
self._update_output_display()
def set_rejected(self) -> None:
"""Mark the tool call as rejected by user."""
self._status = "rejected"
try:
status = self.query_one("#status", Static)
status.remove_class("pending", "success", "error")
status.add_class("rejected")
status.update("[yellow]✗ Rejected[/yellow]")
except NoMatches:
pass
def toggle_output(self) -> None:
"""Toggle between preview and full output display."""
if not self._output:
return
self._expanded = not self._expanded
self._update_output_display()
def _update_output_display(self) -> None:
"""Update the output display based on expanded state."""
if not self._output:
return
try:
preview = self.query_one("#output-preview", Static)
hint = self.query_one("#output-hint", Static)
full = self.query_one("#output-full", Static)
output_stripped = self._output.strip()
lines = output_stripped.split("\n")
total_lines = len(lines)
total_chars = len(output_stripped)
# Truncate if too many lines OR too many characters
needs_truncation = (
total_lines > self._PREVIEW_LINES or total_chars > self._PREVIEW_CHARS
)
if self._expanded:
# Show full output
preview.display = False
hint.display = False
full.update(self._output)
full.display = True
else:
# Show preview
full.display = False
if needs_truncation:
# Truncate by lines first, then by chars
if total_lines > self._PREVIEW_LINES:
preview_text = "\n".join(lines[: self._PREVIEW_LINES])
else:
preview_text = output_stripped
# Also truncate by chars if still too long
if len(preview_text) > self._PREVIEW_CHARS:
preview_text = preview_text[: self._PREVIEW_CHARS] + "..."
preview.update(preview_text)
preview.display = True
# Show expand hint
hint.update("[dim]... (Ctrl+O to expand)[/dim]")
hint.display = True
elif output_stripped:
# Output fits in preview, just show it
preview.update(output_stripped)
preview.display = True
hint.display = False
else:
preview.display = False
hint.display = False
except NoMatches:
pass
@property
def has_output(self) -> bool:
"""Check if this tool message has output to display."""
return bool(self._output)
def _filtered_args(self) -> dict[str, Any]:
"""Filter large tool args for display."""
if self._tool_name not in {"write_file", "edit_file"}:
return self._args
filtered: dict[str, Any] = {}
for key in ("file_path", "path", "replace_all"):
if key in self._args:
filtered[key] = self._args[key]
return filtered
class DiffMessage(Static):
"""Widget displaying a diff with syntax highlighting."""
DEFAULT_CSS = """
DiffMessage {
height: auto;
padding: 1;
margin: 1 0;
background: $surface;
border: solid $primary;
}
DiffMessage .diff-header {
text-style: bold;
margin-bottom: 1;
}
DiffMessage .diff-add {
color: #10b981;
background: #10b98120;
}
DiffMessage .diff-remove {
color: #ef4444;
background: #ef444420;
}
DiffMessage .diff-context {
color: $text-muted;
}
DiffMessage .diff-hunk {
color: $secondary;
text-style: bold;
}
"""
def __init__(self, diff_content: str, file_path: str = "", **kwargs: Any) -> None:
"""Initialize a diff message.
Args:
diff_content: The unified diff content
file_path: Path to the file being modified
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
self._diff_content = diff_content
self._file_path = file_path
def compose(self) -> ComposeResult:
"""Compose the diff message layout."""
if self._file_path:
yield Static(f"[bold]File: {self._file_path}[/bold]", classes="diff-header")
# Render the diff with enhanced formatting
rendered = format_diff_textual(self._diff_content, max_lines=100)
yield Static(rendered)
class ErrorMessage(Static):
"""Widget displaying an error message."""
DEFAULT_CSS = """
ErrorMessage {
height: auto;
padding: 1;
margin: 1 0;
background: #7f1d1d;
color: white;
border-left: thick $error;
}
"""
def __init__(self, error: str, **kwargs: Any) -> None:
"""Initialize an error message.
Args:
error: The error message
**kwargs: Additional arguments passed to parent
"""
super().__init__(f"[bold red]Error:[/bold red] {error}", **kwargs)
class SystemMessage(Static):
"""Widget displaying a system message."""
DEFAULT_CSS = """
SystemMessage {
height: auto;
padding: 0 1;
margin: 1 0;
color: $text-muted;
text-style: italic;
}
"""
def __init__(self, message: str, **kwargs: Any) -> None:
"""Initialize a system message.
Args:
message: The system message
**kwargs: Additional arguments passed to parent
"""
super().__init__(f"[dim]{message}[/dim]", **kwargs)