- Context_Engineering.md: 에이전트 컨텍스트 엔지니어링 개념 정리 문서 추가 - Context_Engineering_Research.ipynb: 연구 노트북 업데이트 - deepagents_sourcecode/: docstring과 주석을 한국어로 번역
511 lines
14 KiB
Python
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)
|