diff --git a/src/drs/chat_gantt_tui.py b/src/drs/chat_gantt_tui.py index 9d5f16c..9d96a92 100644 --- a/src/drs/chat_gantt_tui.py +++ b/src/drs/chat_gantt_tui.py @@ -49,6 +49,49 @@ ROW_FIXED_WIDTH = 2 + ROW_LABEL_WIDTH + LABEL_WIDTH + 1 + 12 +@dataclass(frozen=True) +class GanttPalette: + """Theme-aware colors for the Gantt TUI.""" + + axis: str + think_time: str + label: str + muted_label: str + panel_border: str + summary_border: str + selection_border: str + + +def _theme_is_dark(app: App | None) -> bool: + """Return whether the active Textual theme is dark.""" + if app is None: + return True + return app.current_theme.dark + + +def _palette(app: App | None) -> GanttPalette: + """Choose foreground colors that work on transparent light or dark terminals.""" + if _theme_is_dark(app): + return GanttPalette( + axis="grey70", + think_time="grey50", + label="white", + muted_label="grey70", + panel_border="bright_blue", + summary_border="cyan", + selection_border="green", + ) + return GanttPalette( + axis="grey30", + think_time="grey50", + label="black", + muted_label="grey30", + panel_border="blue", + summary_border="dark_cyan", + selection_border="dark_green", + ) + + def _header_chart_width(viewport_width: int) -> int: """Choose a header chart width that follows the viewport size.""" return max(viewport_width - 4, 40) @@ -75,7 +118,7 @@ def _span_marker(span: ToolSpan) -> tuple[str, str]: if span.failed: return "●", "red" if span.name == "thinkTime": - return "•", "bright_black" + return "•", "grey50" return "●", "green" @@ -181,6 +224,7 @@ def __init__(self, timeline: ToolTimeline, **kwargs) -> None: self.timeline = timeline def render(self) -> RenderableType: + palette = _palette(self.app if self.is_mounted else None) width = _header_chart_width(self.size.width) chart_lines = [] axis_ticks = 5 @@ -195,12 +239,12 @@ def render(self) -> RenderableType: for off, ch in enumerate(label): labels[start_idx + off] = ch - chart_lines.append(Text("".join(labels), style="dim")) - chart_lines.append(Text("".join(markers), style="dim")) + chart_lines.append(Text("".join(labels), style=palette.axis)) + chart_lines.append(Text("".join(markers), style=palette.axis)) legend = Text("Legend: ", style="bold") legend.append("● error ", style="red") legend.append("● success ", style="green") - legend.append("■ think time ", style="bright_black") + legend.append("■ think time ", style=palette.think_time) legend.append("■ short (<5%) ", style="green") legend.append("■ medium (5-15%) ", style="yellow") legend.append("■ long (15-30%) ", style="magenta") @@ -215,7 +259,7 @@ def render(self) -> RenderableType: f"{format_duration_ms(self.timeline.history_bounds.total_ms)} total time " "↑/↓ select ←/→ pan Enter details" ) - return Panel(body, title="Tool Timeline", subtitle=subtitle, border_style="blue") + return Panel(body, title="Tool Timeline", subtitle=subtitle, border_style=palette.panel_border) class GanttRows(Static): @@ -228,6 +272,7 @@ def __init__(self, timeline: ToolTimeline, **kwargs) -> None: self.timeline = timeline def render(self) -> RenderableType: + palette = _palette(self.app if self.is_mounted else None) label_width = LABEL_WIDTH width = _row_bar_width(self.size.width) chart_lines = [] @@ -240,7 +285,7 @@ def render(self) -> RenderableType: color = ( "red" if span.failed - else "bright_black" + else palette.think_time if span.name == "thinkTime" else _duration_color(span.duration_ms, self.timeline.total_ms) ) @@ -257,11 +302,13 @@ def render(self) -> RenderableType: bar.append(f"Step {span.step}".ljust(ROW_LABEL_WIDTH), style=f"bold {line_style}".strip()) bar.append(f"{marker} ", style=f"{marker_style} {line_style}".strip()) bar.append( - truncate_label(span.label, label_width - 2).ljust(label_width), style=f"white {line_style}".strip() + truncate_label(span.label, label_width - 2).ljust(label_width), + style=f"{palette.label} {line_style}".strip(), ) bar.append(" ", style=line_style) bar.append("".join(fill), style=bar_style) - bar.append(f" {format_duration_ms(span.duration_ms)}", style=f"dim {line_style}".strip()) + duration_style = "red" if span.failed else palette.muted_label + bar.append(f" {format_duration_ms(span.duration_ms)}", style=f"{duration_style} {line_style}".strip()) chart_lines.append(bar) return Group(*chart_lines) @@ -299,17 +346,19 @@ class ToolDetails(Static): """Details pane for the selected tool call.""" def show_placeholder(self) -> None: + palette = _palette(self.app if self.is_mounted else None) self.update( Panel( - Text("Select a Gantt row with ↑/↓ and press Enter to view details.", style="dim"), + Text("Select a Gantt row with ↑/↓ and press Enter to view details.", style=palette.muted_label), title="Selection", - border_style="green", + border_style=palette.selection_border, ) ) def show_span(self, span: ToolSpan) -> None: sections = _build_span_sections(span, self.app.timeline) - self.update(Panel(Group(*sections), title="Selection", border_style="green")) + palette = _palette(self.app if self.is_mounted else None) + self.update(Panel(Group(*sections), title="Selection", border_style=palette.selection_border)) class ToolDetailModal(ModalScreen[None]): @@ -318,17 +367,19 @@ class ToolDetailModal(ModalScreen[None]): CSS = """ ToolDetailModal { align: center middle; + background: transparent; } #detail-modal { width: 88%; height: 88%; border: round $accent; - background: $surface; + background: transparent; padding: 1 2; } #detail-modal-body { height: 1fr; width: 1fr; + background: transparent; } """ @@ -368,31 +419,39 @@ class ChatGanttApp(App[None]): CSS = """ Screen { layout: vertical; + background: transparent; } #main { height: 1fr; + background: transparent; } #table-pane { width: 48; min-width: 36; + background: transparent; } #spans { height: 1fr; + background: transparent; } #chart-scroll { height: 1fr; width: 1fr; + background: transparent; } #chart-header-scroll { height: 6; width: 1fr; + background: transparent; } #details { height: 12; + background: transparent; } #summary { height: 7; min-height: 7; + background: transparent; } """ @@ -425,6 +484,7 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + palette = _palette(self) table = self.query_one("#spans", DataTable) table.cursor_type = "row" table.zebra_stripes = True @@ -435,16 +495,17 @@ def on_mount(self) -> None: offset_cell: str | Text duration_cell: str | Text if span.name == "thinkTime": - tool_cell = Text.assemble(("• ", "bright_black"), ("think time", "dim")) - offset_cell = Text("", style="dim") - duration_cell = Text(format_duration_ms(span.duration_ms), style="dim") + tool_cell = Text.assemble(("• ", palette.think_time), ("think time", palette.muted_label)) + offset_cell = Text("", style=palette.muted_label) + duration_cell = Text(format_duration_ms(span.duration_ms), style=palette.muted_label) else: marker, marker_style = _span_marker(span) tool_cell = Text.assemble( - (f"{marker} ", marker_style), (truncate_label(span.name, 22), "red" if span.failed else "") + (f"{marker} ", marker_style), + (truncate_label(span.name, 22), "red" if span.failed else palette.label), ) offset_cell = format_duration_ms(span.offset_ms) - duration_cell = Text(format_duration_ms(span.duration_ms), style="red" if span.failed else "") + duration_cell = Text(format_duration_ms(span.duration_ms), style="red" if span.failed else palette.label) table.add_row( str(span.step), tool_cell, @@ -470,7 +531,7 @@ def on_mount(self) -> None: ) ), title="Summary", - border_style="cyan", + border_style=palette.summary_border, ) ) table.focus() diff --git a/src/drs/chat_render.py b/src/drs/chat_render.py index 0746211..bc584fc 100644 --- a/src/drs/chat_render.py +++ b/src/drs/chat_render.py @@ -20,6 +20,7 @@ import json import sys import threading +from datetime import datetime from typing import Any from rich.console import Console @@ -33,6 +34,77 @@ _SPINNER_INTERVAL = 0.08 +def extract_model_text(name: str, result: dict[str, Any]) -> str: + """Extract the most useful user-facing text from a model result payload.""" + title = result.get("title") + summary = result.get("summary") + if isinstance(title, str) and title.strip() and isinstance(summary, str) and summary.strip(): + return f"{title}\n\n{summary}" + + for key in ("text", "response", "answer", "explanation", "sql_query", "plan", "title"): + value = result.get(key) + if isinstance(value, str) and value.strip(): + return value + + if name == "modelRequestToolApproval": + tool_requests = result.get("toolRequests") + if isinstance(tool_requests, list) and tool_requests: + titles: list[str] = [] + for request in tool_requests: + if not isinstance(request, dict): + continue + title = request.get("summarizedTitle") or request.get("name") + if isinstance(title, str) and title.strip(): + titles.append(title.strip()) + if titles: + return "Tool approval required:\n" + "\n".join(f"- {title}" for title in titles) + return "Tool approval required." + + if result: + return json.dumps(result, indent=2, default=str) + return "" + + +def _format_tool_result(result: Any, max_len: int | None = 500) -> str: + """Return a readable tool result string with optional truncation.""" + if isinstance(result, dict): + text = json.dumps(result, indent=2, default=str) + elif isinstance(result, str): + text = result + else: + text = str(result) + + if max_len is not None and len(text) > max_len: + return text[:max_len] + "\n..." + return text + + +def _parse_timestamp(value: str | None) -> datetime | None: + """Parse ISO-8601 timestamps emitted by the chat API.""" + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + +def _format_timestamp(value: str | None) -> str: + ts = _parse_timestamp(value) + if ts is None: + return "" + return ts.strftime("%H:%M:%S") + + +def _format_duration_ms(duration_ms: float | None) -> str: + if duration_ms is None: + return "" + if duration_ms < 1000: + return f"{int(duration_ms)} ms" + return f"{duration_ms / 1000:.2f}s" + + class _Spinner: """A lightweight terminal spinner that does NOT use Rich's Live display. @@ -77,15 +149,16 @@ def _run(self) -> None: class ChatRenderer: """Renders agent SSE events to a Rich console (interactive mode).""" - def __init__(self, console: Console | None = None) -> None: + def __init__(self, console: Console | None = None, show_tool_details: bool = False) -> None: self.console = console or Console() self._spinner: _Spinner | None = None + self._show_tool_details = show_tool_details # -- Model output -- def render_model_chunk(self, name: str, result: dict) -> None: """Render a model output chunk based on the task type.""" - text = result.get("text", "") + text = extract_model_text(name, result) if not text: return @@ -105,6 +178,7 @@ def render_tool_request( name: str, arguments: dict | None = None, title: str | None = None, + created_at: str | None = None, ) -> None: """Show a tool call request in a bordered panel.""" display_name = title or name @@ -112,21 +186,39 @@ def render_tool_request( if arguments: args_summary = _summarize_args(arguments) - body = Text(args_summary, style="dim") if args_summary else Text("(no arguments)", style="dim") + body_lines: list[str] = [] + if self._show_tool_details: + formatted_time = _format_timestamp(created_at) + if formatted_time: + body_lines.append(f"Started: {formatted_time}") + body_lines.append(args_summary or "(no arguments)") + body = Text("\n".join(body_lines), style="dim") self.console.print( Panel(body, title=f"[bold cyan]Tool: {display_name}[/]", border_style="cyan", expand=False), ) - def render_tool_response(self, call_id: str, name: str, result: Any) -> None: + def render_tool_response( + self, + call_id: str, + name: str, + result: Any, + created_at: str | None = None, + duration_ms: float | None = None, + ) -> None: """Show a tool result in a muted panel.""" - if isinstance(result, dict): - text = json.dumps(result, indent=2, default=str) - if len(text) > 500: - text = text[:500] + "\n..." - elif isinstance(result, str): - text = result[:500] + ("..." if len(result) > 500 else "") + if self._show_tool_details: + meta: list[str] = [] + formatted_time = _format_timestamp(created_at) + formatted_duration = _format_duration_ms(duration_ms) + if formatted_time: + meta.append(f"Finished: {formatted_time}") + if formatted_duration: + meta.append(f"Duration: {formatted_duration}") + text = _format_tool_result(result, max_len=None) + if meta: + text = "\n".join(meta) + "\n\n" + text else: - text = str(result)[:500] + text = _format_tool_result(result) self.console.print( Panel(Text(text, style="dim"), title=f"[dim]{name} result[/]", border_style="dim", expand=False), @@ -172,7 +264,7 @@ def prompt_tool_approval(self, nonce: str, tools: list[dict]) -> dict: decisions: list[dict] = [] for tool in tools: tool_name = tool.get("name", "unknown") - tool_id = tool.get("callId", tool.get("id", "")) + tool_id = tool.get("executionId", tool.get("callId", tool.get("id", ""))) args = tool.get("arguments", {}) self.render_tool_request(tool_id, tool_name, args) try: @@ -182,8 +274,10 @@ def prompt_tool_approval(self, nonce: str, tools: list[dict]) -> dict: approved = answer in ("", "y", "yes") decisions.append( { - "callId": tool_id, - "decision": "approved" if approved else "denied", + "executionId": tool_id, + "name": tool_name, + "arguments": args if isinstance(args, dict) else {}, + "approved": approved, } ) return { @@ -235,14 +329,15 @@ class PlainRenderer: Tool events and progress always go to stderr. """ - def __init__(self) -> None: + def __init__(self, show_tool_details: bool = False) -> None: self._is_tty = sys.stdout.isatty() self._console = Console() if self._is_tty else None self._stderr_console = Console(stderr=True, highlight=False) self._spinner: _Spinner | None = None + self._show_tool_details = show_tool_details def render_model_chunk(self, name: str, result: dict) -> None: - text = result.get("text", "") + text = extract_model_text(name, result) if not text: return if self._console is not None: @@ -262,15 +357,47 @@ def render_tool_request( name: str, arguments: dict | None = None, title: str | None = None, + created_at: str | None = None, ) -> None: - self._stderr_console.print( - Text(f" ⚙ {title or name}", style="dim cyan"), - ) + if self._show_tool_details: + display_name = title or name + header = f" ⚙ {display_name}" + formatted_time = _format_timestamp(created_at) + if formatted_time: + header += f" [{formatted_time}]" + args_summary = _summarize_args(arguments) if arguments else "(no arguments)" + self._stderr_console.print( + Panel(Text(args_summary, style="dim"), title=header, border_style="cyan", expand=False), + ) + return + self._stderr_console.print(Text(f" ⚙ {title or name}", style="dim cyan")) - def render_tool_response(self, call_id: str, name: str, result: Any) -> None: - self._stderr_console.print( - Text(f" ✓ {name} done", style="dim"), - ) + def render_tool_response( + self, + call_id: str, + name: str, + result: Any, + created_at: str | None = None, + duration_ms: float | None = None, + ) -> None: + if self._show_tool_details: + header = f" ✓ {name}" + formatted_duration = _format_duration_ms(duration_ms) + if formatted_duration: + header += f" ({formatted_duration})" + formatted_time = _format_timestamp(created_at) + if formatted_time: + header += f" [{formatted_time}]" + self._stderr_console.print( + Panel( + Text(_format_tool_result(result, max_len=None), style="dim"), + title=header, + border_style="dim", + expand=False, + ), + ) + return + self._stderr_console.print(Text(f" ✓ {name} done", style="dim")) def render_tool_progress(self, status: str, message: str) -> None: self._stderr_console.print( diff --git a/src/drs/commands/chat.py b/src/drs/commands/chat.py index d80bf21..741dc00 100644 --- a/src/drs/commands/chat.py +++ b/src/drs/commands/chat.py @@ -22,6 +22,7 @@ import json import logging import sys +from datetime import datetime from enum import StrEnum from pathlib import Path from typing import Any @@ -38,7 +39,15 @@ from rich.text import Text from drs.chat_gantt import load_history_dump, render_tool_gantt -from drs.chat_render import ChatRenderer, PlainRenderer +from drs.chat_render import ( + ChatRenderer, + PlainRenderer, + _format_duration_ms, + _format_timestamp, + _format_tool_result, + _summarize_args, + extract_model_text, +) from drs.client import DremioClient from drs.output import error as print_error from drs.sse import parse_sse_stream @@ -106,8 +115,10 @@ def _render_conversations_table(console: Console, rows: list[dict]) -> None: console.print(table) -def _render_history_table(console: Console, rows: list[dict]) -> None: +def _render_history_table(console: Console, rows: list[dict], show_tool_details: bool = False) -> None: """Render conversation history as a readable transcript.""" + tool_started_at: dict[str, datetime] = {} + for row in rows: chunk_type = row.get("chunkType", "") timestamp = str(row.get("createdAt", "")) @@ -121,7 +132,7 @@ def _render_history_table(console: Console, rows: list[dict]) -> None: elif chunk_type == "model": result = row.get("result", {}) - text = result.get("text", "") if isinstance(result, dict) else str(result) + text = extract_model_text(row.get("name", ""), result) if isinstance(result, dict) else str(result) name = row.get("name", "") title = "[bold blue]Agent[/]" if name and name != "modelGeneric": @@ -132,11 +143,60 @@ def _render_history_table(console: Console, rows: list[dict]) -> None: elif chunk_type == "toolRequest": tool_name = row.get("name", "") summarized = row.get("summarizedTitle", tool_name) - console.print(Text(f" ⚙ {summarized}", style="dim cyan")) + call_id = row.get("callId", "") + started_at = _parse_event_timestamp(row.get("createdAt")) + if call_id and started_at is not None: + tool_started_at[call_id] = started_at + if show_tool_details: + formatted_time = _format_timestamp(row.get("createdAt")) + args_summary = "" + arguments = row.get("arguments") + if isinstance(arguments, dict): + args_summary = _summarize_args(arguments) + details = [] + if formatted_time: + details.append(f"Started: {formatted_time}") + details.append(args_summary or "(no arguments)") + console.print( + Panel( + Text("\n".join(details), style="dim"), + title=f"[bold cyan]Tool: {summarized}[/]", + border_style="cyan", + expand=False, + ) + ) + else: + console.print(Text(f" ⚙ {summarized}", style="dim cyan")) elif chunk_type == "toolResponse": tool_name = row.get("name", "") - console.print(Text(f" ✓ {tool_name} done", style="dim")) + if show_tool_details: + call_id = row.get("callId", "") + finished_at = _parse_event_timestamp(row.get("createdAt")) + started_at = tool_started_at.pop(call_id, None) + duration_ms = None + if started_at is not None and finished_at is not None: + duration_ms = max((finished_at - started_at).total_seconds() * 1000, 0.0) + meta: list[str] = [] + formatted_time = _format_timestamp(row.get("createdAt")) + formatted_duration = _format_duration_ms(duration_ms) + if formatted_time: + meta.append(f"Finished: {formatted_time}") + if formatted_duration: + meta.append(f"Duration: {formatted_duration}") + body = _format_tool_result(row.get("result"), max_len=None) + if meta: + body = "\n".join(meta) + "\n\n" + body + console.print( + Panel( + Text(body, style="dim"), + title=f"[dim]{tool_name} result[/]", + border_style="dim", + expand=False, + ) + ) + else: + console.print(Text(f" ✓ {tool_name} done", style="dim")) def _render_generic_table(console: Console, rows: list[dict]) -> None: @@ -163,7 +223,7 @@ async def create_conversation( """POST /agent/conversations — start a new conversation.""" body: dict[str, Any] = {"prompt": {"text": text}} if model: - body["model"] = model + body["modelName"] = model try: return await client.create_conversation(body) except httpx.HTTPStatusError as exc: @@ -184,7 +244,7 @@ async def send_message( if approvals: body["prompt"]["approvals"] = approvals if model: - body["model"] = model + body["modelName"] = model try: return await client.send_conversation_message(conversation_id, body) except httpx.HTTPStatusError as exc: @@ -256,6 +316,61 @@ def _extract_ids(result: dict) -> tuple[str | None, str | None]: return conv_id, run_id +def _extract_tool_approval(data: dict[str, Any]) -> tuple[str, list[dict[str, Any]]] | None: + """Extract a tool approval request from either legacy or model-based chunks.""" + if data.get("chunkType") == "interrupt": + nonce = data.get("approvalNonce") + tools = data.get("toolDecisions") + if isinstance(nonce, str) and isinstance(tools, list): + return nonce, tools + + if data.get("chunkType") != "model" or data.get("name") != "modelRequestToolApproval": + return None + + result = data.get("result") + if not isinstance(result, dict): + return None + + nonce = result.get("approvalNonce") + tool_requests = result.get("toolRequests") + if not isinstance(nonce, str) or not isinstance(tool_requests, list): + return None + return nonce, tool_requests + + +def _build_approval_payload(nonce: str, tools: list[dict[str, Any]], auto_approve: bool) -> dict[str, Any]: + """Convert streamed approval requests into the v2 approvals payload.""" + decisions: list[dict[str, Any]] = [] + for tool in tools: + execution_id = tool.get("executionId", tool.get("callId", tool.get("id", ""))) + name = tool.get("name", "") + arguments = tool.get("arguments", {}) + if not execution_id or not name: + continue + decisions.append( + { + "executionId": execution_id, + "name": name, + "arguments": arguments if isinstance(arguments, dict) else {}, + "approved": auto_approve, + } + ) + return { + "approvalNonce": nonce, + "toolDecisions": decisions, + } + + +def _parse_event_timestamp(value: Any) -> datetime | None: + """Parse an event timestamp emitted by the chat API.""" + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + # --------------------------------------------------------------------------- # SSE event dispatch # --------------------------------------------------------------------------- @@ -276,6 +391,7 @@ async def dispatch_events( """ renderer.start_spinner() first_model_chunk = True + tool_started_at: dict[str, datetime] = {} try: async for event in stream_run(client, conversation_id, run_id): @@ -295,22 +411,63 @@ async def dispatch_events( result = data.get("result", {}) renderer.render_model_chunk(name, result) + approval_request = _extract_tool_approval(data) + if approval_request is not None: + nonce, tools = approval_request + if interactive and isinstance(renderer, ChatRenderer): + approvals = renderer.prompt_tool_approval(nonce, tools) + else: + approvals = _build_approval_payload(nonce, tools, auto_approve) + + resp = await send_message( + client, + conversation_id, + approvals=approvals, + ) + _, new_run_id = _extract_ids(resp) + if new_run_id: + run_id = new_run_id + renderer.start_spinner() + first_model_chunk = True + return await dispatch_events( + client, + renderer, + conversation_id, + run_id, + auto_approve=auto_approve, + interactive=interactive, + log_file=log_file, + ) + elif chunk_type == "toolRequest": renderer.stop_spinner() + call_id = data.get("callId", "") + started_at = _parse_event_timestamp(data.get("createdAt")) + if call_id and started_at is not None: + tool_started_at[call_id] = started_at renderer.render_tool_request( - call_id=data.get("callId", ""), + call_id=call_id, name=data.get("name", ""), arguments=data.get("arguments"), title=data.get("summarizedTitle"), + created_at=data.get("createdAt"), ) renderer.start_spinner() elif chunk_type == "toolResponse": renderer.stop_spinner() + call_id = data.get("callId", "") + finished_at = _parse_event_timestamp(data.get("createdAt")) + started_at = tool_started_at.pop(call_id, None) + duration_ms = None + if started_at is not None and finished_at is not None: + duration_ms = max((finished_at - started_at).total_seconds() * 1000, 0.0) renderer.render_tool_response( - call_id=data.get("callId", ""), + call_id=call_id, name=data.get("name", ""), result=data.get("result"), + created_at=data.get("createdAt"), + duration_ms=duration_ms, ) renderer.start_spinner() @@ -329,21 +486,15 @@ async def dispatch_events( elif chunk_type == "interrupt": renderer.stop_spinner() - nonce = data.get("approvalNonce", "") - tools = data.get("toolDecisions", []) + approval_request = _extract_tool_approval(data) + if approval_request is None: + continue + nonce, tools = approval_request if interactive and isinstance(renderer, ChatRenderer): approvals = renderer.prompt_tool_approval(nonce, tools) else: - decisions = [] - for tool in tools: - decisions.append( - { - "callId": tool.get("callId", tool.get("id", "")), - "decision": "approved" if auto_approve else "denied", - } - ) - approvals = {"approvalNonce": nonce, "toolDecisions": decisions} + approvals = _build_approval_payload(nonce, tools, auto_approve) resp = await send_message( client, @@ -541,9 +692,10 @@ async def chat_oneshot( auto_approve: bool = False, model: str | None = None, log_file: Any | None = None, + show_tool_details: bool = False, ) -> None: """Send a single message and stream the response to stdout.""" - renderer = PlainRenderer() + renderer = PlainRenderer(show_tool_details=show_tool_details) if conversation_id is None: result = await create_conversation(client, message, model=model) @@ -593,6 +745,11 @@ def chat_main( auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve tool calls (non-interactive only)"), log_file: str | None = typer.Option(None, "--log-file", help="Path to JSON-lines event log file"), model: str | None = typer.Option(None, "--model", help="Model override"), + show_tool_details: bool = typer.Option( + False, + "--show-tool-details", + help="Show tool timestamps, durations, and detailed tool results", + ), ) -> None: """Chat with the Dremio AI Agent. Launches interactive REPL by default.""" if ctx.invoked_subcommand is not None: @@ -615,9 +772,10 @@ async def _run() -> None: auto_approve=auto_approve, model=model, log_file=log_fh, + show_tool_details=show_tool_details, ) else: - renderer = ChatRenderer() + renderer = ChatRenderer(show_tool_details=show_tool_details) await chat_repl( client, renderer, @@ -674,6 +832,11 @@ def chat_history( ascii: bool = typer.Option( False, "--ascii", help="With --gantt, render plain ASCII output instead of launching the Textual TUI" ), + show_tool_details: bool = typer.Option( + False, + "--show-tool-details", + help="Show tool timestamps, durations, and detailed tool results in transcript output", + ), fmt: ChatFormat = typer.Option(ChatFormat.table, "--format", "-f", help="Output format: json, table"), ) -> None: """Show message history for a conversation.""" @@ -702,6 +865,12 @@ async def _run(): except ValueError as exc: print_error(f"Unable to render Gantt chart: {exc}") raise typer.Exit(1) + if fmt == ChatFormat.table: + console = Console() + rows = result.get("data", result.get("messages", [])) + if rows and isinstance(rows, list) and isinstance(rows[0], dict) and "chunkType" in rows[0]: + _render_history_table(console, rows, show_tool_details=show_tool_details) + return _chat_output(result, fmt) diff --git a/tests/test_chat_gantt_tui.py b/tests/test_chat_gantt_tui.py new file mode 100644 index 0000000..9885d91 --- /dev/null +++ b/tests/test_chat_gantt_tui.py @@ -0,0 +1,39 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for theme-aware chat Gantt TUI colors.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from drs.chat_gantt_tui import _palette + + +def test_palette_defaults_to_dark_mode() -> None: + palette = _palette(None) + + assert palette.label == "white" + assert palette.panel_border == "bright_blue" + + +def test_palette_switches_for_light_theme() -> None: + app = SimpleNamespace(current_theme=SimpleNamespace(dark=False)) + + palette = _palette(app) + + assert palette.label == "black" + assert palette.panel_border == "blue" + assert palette.selection_border == "dark_green" diff --git a/tests/test_chat_render.py b/tests/test_chat_render.py new file mode 100644 index 0000000..9329b0e --- /dev/null +++ b/tests/test_chat_render.py @@ -0,0 +1,67 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for chat renderer detail output.""" + +from __future__ import annotations + +from io import StringIO + +from rich.console import Console + +from drs.chat_render import ChatRenderer, PlainRenderer, extract_model_text + + +def test_extract_model_text_prefers_title_and_summary() -> None: + text = extract_model_text( + "modelSqlAnswer", + {"title": "Direct Reports", "summary": "Three people report to Myra."}, + ) + + assert text == "Direct Reports\n\nThree people report to Myra." + + +def test_chat_renderer_tool_response_details_include_duration() -> None: + stream = StringIO() + renderer = ChatRenderer(console=Console(file=stream, force_terminal=False), show_tool_details=True) + + renderer.render_tool_response( + call_id="call-1", + name="runSql", + result={"rows": [["Lesley Ellis"]]}, + created_at="2026-07-31T12:10:35.270Z", + duration_ms=1803, + ) + + output = stream.getvalue() + assert "Finished: 12:10:35" in output + assert "Duration: 1.80s" in output + assert '"rows"' in output + + +def test_plain_renderer_tool_request_details_include_timestamp(capsys) -> None: + renderer = PlainRenderer(show_tool_details=True) + + renderer.render_tool_request( + call_id="call-1", + name="runSql", + arguments={"sqlText": "select 1"}, + title="Run direct reports query", + created_at="2026-07-31T12:10:33.467Z", + ) + + captured = capsys.readouterr() + assert "Run direct reports query [12:10:33]" in captured.err + assert "sqlText=select 1" in captured.err diff --git a/tests/test_commands/test_chat.py b/tests/test_commands/test_chat.py index 2490418..8af9bff 100644 --- a/tests/test_commands/test_chat.py +++ b/tests/test_commands/test_chat.py @@ -17,6 +17,7 @@ from __future__ import annotations +from io import StringIO from unittest.mock import AsyncMock import pytest @@ -32,6 +33,9 @@ ) from drs.chat_gantt_tui import ToolTimeline, _build_span_sections from drs.commands.chat import ( + _build_approval_payload, + _extract_tool_approval, + _render_history_table, cancel_run, create_conversation, delete_conversation, @@ -59,7 +63,7 @@ async def test_create_conversation_with_model(mock_client) -> None: mock_client.create_conversation = AsyncMock(return_value={"id": "conv-1"}) await create_conversation(mock_client, "hello", model="gpt-test") call_args = mock_client.create_conversation.call_args[0][0] - assert call_args["model"] == "gpt-test" + assert call_args["modelName"] == "gpt-test" @pytest.mark.asyncio @@ -89,6 +93,54 @@ async def test_send_message_approval(mock_client) -> None: assert result["runId"] == "run-3" +def test_extract_tool_approval_from_model_chunk() -> None: + event = { + "chunkType": "model", + "name": "modelRequestToolApproval", + "result": { + "approvalNonce": "nonce-1", + "toolRequests": [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + } + ], + }, + } + + assert _extract_tool_approval(event) == ( + "nonce-1", + event["result"]["toolRequests"], + ) + + +def test_build_approval_payload_uses_v2_shape() -> None: + approvals = _build_approval_payload( + "nonce-1", + [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + } + ], + auto_approve=True, + ) + + assert approvals == { + "approvalNonce": "nonce-1", + "toolDecisions": [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + "approved": True, + } + ], + } + + @pytest.mark.asyncio async def test_list_conversations(mock_client) -> None: mock_client.list_conversations = AsyncMock( @@ -209,6 +261,63 @@ def test_build_history_bounds_uses_all_events() -> None: assert bounds.total_ms == 17206 +def test_render_history_table_uses_model_summary_text() -> None: + stream = StringIO() + console = Console(file=stream, force_terminal=False) + + _render_history_table( + console, + [ + { + "chunkType": "model", + "createdAt": "2026-07-31T12:10:37.969Z", + "name": "modelSqlAnswer", + "result": { + "title": "Direct Reports to Myra Richmond", + "summary": "Myra Richmond has three direct reports.", + }, + } + ], + ) + + output = stream.getvalue() + assert "Direct Reports to Myra Richmond" in output + assert "Myra Richmond has three direct reports." in output + + +def test_render_history_table_shows_tool_timing_details() -> None: + stream = StringIO() + console = Console(file=stream, force_terminal=False) + + _render_history_table( + console, + [ + { + "chunkType": "toolRequest", + "callId": "call-1", + "name": "runSql", + "summarizedTitle": "Run report query", + "createdAt": "2026-07-31T12:10:33.467Z", + "arguments": {"sqlText": "select 1"}, + }, + { + "chunkType": "toolResponse", + "callId": "call-1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:35.270Z", + "result": {"rows": [["Lesley Ellis"]]}, + }, + ], + show_tool_details=True, + ) + + output = stream.getvalue() + assert "Started: 12:10:33" in output + assert "Finished: 12:10:35" in output + assert "Duration: 1.80s" in output + assert '"rows"' in output + + def test_build_tool_spans_can_insert_think_time() -> None: rows = [ {