Skip to content

Commit 6cf1128

Browse files
evalstateclaude
andauthored
Add FAST_AGENT_TIMING channel for instrumentation (#457)
* Add timing column to /history command display - Add timing extraction helper function to get duration_ms from FAST_AGENT_TIMING channel - Update /history command to display turn time between Role and Chars columns - Display "-" when timing data is not available (e.g., for tool results) - Modernize type hints to use Python 3.10+ syntax (X | None instead of Optional[X]) - Update imports to use collections.abc directly for Mapping and Sequence The timing data is already captured consistently in both generate() and structured() methods in fastagent_llm.py, so this change only adds the display functionality. * Make /history display responsive to terminal width - >= 80 cols: Show all columns (Role, Time, Chars, Summary) - >= 60 cols: Show Role, Time, Chars, Summary - >= 50 cols: Show Role, Chars, Summary (drop Time) - < 50 cols: Show Role, Summary (drop Time and Chars) This ensures the Summary column always has adequate space on narrower terminals by progressively hiding less critical columns. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 931df1c commit 6cf1128

File tree

1 file changed

+74
-25
lines changed

1 file changed

+74
-25
lines changed

src/fast_agent/ui/history_display.py

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
from __future__ import annotations
44

5+
import json
6+
from collections.abc import Mapping, Sequence
57
from shutil import get_terminal_size
6-
from typing import TYPE_CHECKING, Optional, Sequence
8+
from typing import TYPE_CHECKING
79

810
from rich import print as rich_print
911
from rich.text import Text
1012

13+
from fast_agent.constants import FAST_AGENT_TIMING
1114
from fast_agent.mcp.helpers.content_helpers import get_text
1215

1316
if TYPE_CHECKING: # pragma: no cover - typing only
14-
from collections.abc import Mapping
15-
1617
from rich.console import Console
1718

1819
from fast_agent.llm.usage_tracking import UsageAccumulator
@@ -25,7 +26,7 @@
2526
ROLE_COLUMN_WIDTH = 17
2627

2728

28-
def _normalize_text(value: Optional[str]) -> str:
29+
def _normalize_text(value: str | None) -> str:
2930
return "" if not value else " ".join(value.split())
3031

3132

@@ -44,7 +45,7 @@ class Colours:
4445
TOOL_DETAIL = "dim magenta"
4546

4647

47-
def _char_count(value: Optional[str]) -> int:
48+
def _char_count(value: str | None) -> int:
4849
return len(_normalize_text(value))
4950

5051

@@ -81,10 +82,10 @@ def _truncate_text_segment(segment: Text, width: int) -> Text:
8182

8283
def _compose_summary_text(
8384
preview: Text,
84-
detail: Optional[Text],
85+
detail: Text | None,
8586
*,
8687
include_non_text: bool,
87-
max_width: Optional[int],
88+
max_width: int | None,
8889
) -> Text:
8990
marker_component = Text()
9091
if include_non_text:
@@ -171,7 +172,7 @@ def _compose_summary_text(
171172
return combined
172173

173174

174-
def _preview_text(value: Optional[str], limit: int = 80) -> str:
175+
def _preview_text(value: str | None, limit: int = 80) -> str:
175176
normalized = _normalize_text(value)
176177
if not normalized:
177178
return "<no text>"
@@ -189,7 +190,7 @@ def _has_non_text_content(message: PromptMessageExtended) -> bool:
189190

190191

191192
def _extract_tool_result_summary(result, *, limit: int = 80) -> tuple[str, int, bool]:
192-
preview: Optional[str] = None
193+
preview: str | None = None
193194
total_chars = 0
194195
saw_non_text = False
195196

@@ -218,6 +219,36 @@ def format_chars(value: int) -> str:
218219
return str(value)
219220

220221

222+
def _extract_timing_ms(message: PromptMessageExtended) -> float | None:
223+
"""Extract timing duration in milliseconds from message channels."""
224+
channels = getattr(message, "channels", None)
225+
if not channels:
226+
return None
227+
228+
timing_blocks = channels.get(FAST_AGENT_TIMING, [])
229+
if not timing_blocks:
230+
return None
231+
232+
timing_text = get_text(timing_blocks[0])
233+
if not timing_text:
234+
return None
235+
236+
try:
237+
timing_data = json.loads(timing_text)
238+
return timing_data.get("duration_ms")
239+
except (json.JSONDecodeError, AttributeError, KeyError):
240+
return None
241+
242+
243+
def format_time(value: float | None) -> str:
244+
"""Format timing value for display."""
245+
if value is None:
246+
return "-"
247+
if value < 1000:
248+
return f"{value:.0f}ms"
249+
return f"{value / 1000:.1f}s"
250+
251+
221252
def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
222253
rows: list[dict] = []
223254
call_name_lookup: dict[str, str] = {}
@@ -238,8 +269,11 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
238269
preview = _preview_text(text)
239270
non_text = _has_non_text_content(message) or chars == 0
240271

241-
tool_calls: Optional[Mapping[str, object]] = getattr(message, "tool_calls", None)
242-
tool_results: Optional[Mapping[str, object]] = getattr(message, "tool_results", None)
272+
# Extract timing data
273+
timing_ms = _extract_timing_ms(message)
274+
275+
tool_calls: Mapping[str, object] | None = getattr(message, "tool_calls", None)
276+
tool_results: Mapping[str, object] | None = getattr(message, "tool_results", None)
243277

244278
detail_sections: list[Text] = []
245279
row_non_text = non_text
@@ -289,6 +323,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
289323
"hide_summary": False,
290324
"include_in_timeline": False,
291325
"is_error": is_error,
326+
"timing_ms": None,
292327
}
293328
)
294329
if role == "user":
@@ -327,6 +362,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
327362
"hide_summary": hide_in_summary,
328363
"include_in_timeline": include_in_timeline,
329364
"is_error": row_is_error,
365+
"timing_ms": timing_ms,
330366
}
331367
)
332368
rows.extend(result_rows)
@@ -392,7 +428,7 @@ def _build_history_bar(entries: Sequence[dict], width: int = TIMELINE_WIDTH) ->
392428

393429
def _build_context_bar_line(
394430
current: int,
395-
window: Optional[int],
431+
window: int | None,
396432
width: int = TIMELINE_WIDTH,
397433
) -> tuple[Text, Text]:
398434
bar = Text(" context |", style="dim")
@@ -427,7 +463,7 @@ def color_for(pct: float) -> str:
427463
return bar, detail
428464

429465

430-
def _render_header_line(agent_name: str, *, console: Optional[Console], printer) -> None:
466+
def _render_header_line(agent_name: str, *, console: Console | None, printer) -> None:
431467
header = Text()
432468
header.append("▎", style=Colours.HEADER)
433469
header.append("●", style=f"dim {Colours.HEADER}")
@@ -454,9 +490,9 @@ def _render_header_line(agent_name: str, *, console: Optional[Console], printer)
454490
def display_history_overview(
455491
agent_name: str,
456492
history: Sequence[PromptMessageExtended],
457-
usage_accumulator: Optional["UsageAccumulator"] = None,
493+
usage_accumulator: "UsageAccumulator" | None = None,
458494
*,
459-
console: Optional[Console] = None,
495+
console: Console | None = None,
460496
) -> None:
461497
if not history:
462498
printer = console.print if console else rich_print
@@ -509,15 +545,6 @@ def display_history_overview(
509545
Text(" " + "─" * (history_bar.cell_len + context_bar.cell_len + gap.cell_len), style="dim")
510546
)
511547

512-
header_line = Text(" ")
513-
header_line.append(" #", style="dim")
514-
header_line.append(" ", style="dim")
515-
header_line.append(f" {'Role':<{ROLE_COLUMN_WIDTH}}", style="dim")
516-
header_line.append(f" {'Chars':>7}", style="dim")
517-
header_line.append(" ", style="dim")
518-
header_line.append("Summary", style="dim")
519-
printer(header_line)
520-
521548
summary_candidates = [row for row in rows if not row.get("hide_summary")]
522549
summary_rows = summary_candidates[-SUMMARY_COUNT:]
523550
start_index = len(summary_candidates) - len(summary_rows) + 1
@@ -530,6 +557,22 @@ def display_history_overview(
530557
except Exception:
531558
total_width = 80
532559

560+
# Responsive column layout based on terminal width
561+
show_time = total_width >= 60
562+
show_chars = total_width >= 50
563+
564+
header_line = Text(" ")
565+
header_line.append(" #", style="dim")
566+
header_line.append(" ", style="dim")
567+
header_line.append(f" {'Role':<{ROLE_COLUMN_WIDTH}}", style="dim")
568+
if show_time:
569+
header_line.append(f" {'Time':>7}", style="dim")
570+
if show_chars:
571+
header_line.append(f" {'Chars':>7}", style="dim")
572+
header_line.append(" ", style="dim")
573+
header_line.append("Summary", style="dim")
574+
printer(header_line)
575+
533576
for offset, row in enumerate(summary_rows):
534577
role = row["role"]
535578
color = _get_role_color(role, is_error=row.get("is_error", False))
@@ -547,6 +590,9 @@ def display_history_overview(
547590
if detail_text.cell_len == 0:
548591
detail_text = None
549592

593+
timing_ms = row.get("timing_ms")
594+
timing_str = format_time(timing_ms)
595+
550596
line = Text(" ")
551597
line.append(f"{start_index + offset:>2}", style="dim")
552598
line.append(" ")
@@ -555,7 +601,10 @@ def display_history_overview(
555601
line.append(arrow, style=color)
556602
line.append(" ")
557603
line.append(f"{label:<{ROLE_COLUMN_WIDTH}}", style=color)
558-
line.append(f" {format_chars(chars):>7}", style="dim")
604+
if show_time:
605+
line.append(f" {timing_str:>7}", style="dim")
606+
if show_chars:
607+
line.append(f" {format_chars(chars):>7}", style="dim")
559608
line.append(" ")
560609
summary_width = max(0, total_width - line.cell_len)
561610
summary_text = _compose_summary_text(

0 commit comments

Comments
 (0)