|
9 | 9 | from collections.abc import Callable, Iterable |
10 | 10 | from dataclasses import dataclass |
11 | 11 | from pathlib import Path |
| 12 | +from typing import Any |
12 | 13 |
|
13 | 14 | from prompt_toolkit import PromptSession |
14 | 15 | from prompt_toolkit.history import FileHistory |
15 | 16 | from rich import box |
16 | 17 | from rich.columns import Columns |
17 | 18 | from rich.console import Console, Group, RenderableType |
18 | 19 | from rich.live import Live |
| 20 | +from rich.markdown import Markdown |
19 | 21 | from rich.panel import Panel |
20 | 22 | from rich.rule import Rule |
21 | 23 | from rich.status import Status |
@@ -711,12 +713,70 @@ def _approval_with_keys(step: ActionStep) -> bool: |
711 | 713 | border_style="bold green", |
712 | 714 | ) |
713 | 715 | ) |
| 716 | + _print_usage_footer(console, store, state.session_id, state.model_name) |
714 | 717 |
|
715 | 718 | # Surface recovery hint when the run ended in a recoverable failure so |
716 | 719 | # users can type ``/retry`` or ``/continue`` at the next prompt. |
717 | 720 | _maybe_print_recovery_hint(console, store, state.session_id) |
718 | 721 |
|
719 | 722 |
|
| 723 | +def _print_usage_footer( |
| 724 | + console: Console, |
| 725 | + store: SessionStoreBase, |
| 726 | + session_id: str, |
| 727 | + model_name: str | None, |
| 728 | +) -> None: |
| 729 | + """Print a single-line token usage summary below the response panel. |
| 730 | +
|
| 731 | + Shows the same faceted view the console footer does: root agent |
| 732 | + headroom vs. model max (the actual context-window pressure), plus a |
| 733 | + sub-agent rollup and compaction count. Numbers come from the one |
| 734 | + ``build_usage_numbers`` helper — no duplicate aggregation here. |
| 735 | + """ |
| 736 | + try: |
| 737 | + from meeseeks_core.token_budget import build_usage_numbers |
| 738 | + |
| 739 | + effective_model = model_name or str( |
| 740 | + get_config_value("llm", "default_model", default="") or "" |
| 741 | + ) |
| 742 | + events = store.load_transcript(session_id) |
| 743 | + u = build_usage_numbers(events, effective_model) |
| 744 | + except Exception: |
| 745 | + return # silent — footer is decorative |
| 746 | + max_in = u["root_max_input_tokens"] |
| 747 | + if max_in <= 0 and u["total_input_tokens_billed"] == 0: |
| 748 | + return # nothing meaningful yet |
| 749 | + pct = int(round(u["root_utilization"] * 100)) |
| 750 | + line = Text() |
| 751 | + line.append(f"{u['root_model']}", style="dim cyan") |
| 752 | + line.append(" ", style="dim") |
| 753 | + line.append( |
| 754 | + f"root {_fmt_tokens(u['root_last_input_tokens'])}/{_fmt_tokens(max_in)} ({pct}%)", |
| 755 | + style="dim", |
| 756 | + ) |
| 757 | + if u["sub_peak_input_tokens"] or u["sub_output_tokens"]: |
| 758 | + # Sub-agents run in isolated contexts; show combined peak pressure |
| 759 | + # (sum of per-agent peaks) + summed output. |
| 760 | + sub_peak = _fmt_tokens(u["sub_peak_input_tokens"]) |
| 761 | + sub_out = _fmt_tokens(u["sub_output_tokens"]) |
| 762 | + line.append(" · sub peak ", style="dim") |
| 763 | + line.append(f"{sub_peak} / {sub_out} out", style="dim") |
| 764 | + line.append(" · ", style="dim") |
| 765 | + line.append(f"{_fmt_tokens(u['tokens_until_compact'])} until compact", style="dim") |
| 766 | + if u["compaction_count"] > 0: |
| 767 | + line.append(f" · ⊙ {u['compaction_count']} compaction(s)", style="dim") |
| 768 | + console.print(line) |
| 769 | + |
| 770 | + |
| 771 | +def _fmt_tokens(n: int) -> str: |
| 772 | + """Format a token count: 5200 → '5.2k', 1500000 → '1.5m'.""" |
| 773 | + if n >= 1_000_000: |
| 774 | + return f"{n / 1_000_000:.1f}m" |
| 775 | + if n >= 1_000: |
| 776 | + return f"{n / 1_000:.1f}k" |
| 777 | + return str(n) |
| 778 | + |
| 779 | + |
720 | 780 | def _maybe_print_recovery_hint( |
721 | 781 | console: Console, store: SessionStoreBase, session_id: str |
722 | 782 | ) -> None: |
@@ -1035,8 +1095,43 @@ def _build_cli_hook_manager( |
1035 | 1095 | ) -> HookManager: |
1036 | 1096 | # When agent display is active, the live tree + integrated spinner |
1037 | 1097 | # replace the per-tool console.status() spinner. |
1038 | | - def _on_compact(session_id: str) -> None: |
1039 | | - console.print("[dim blue]Context compacted[/dim blue]") |
| 1098 | + def _on_compact(session_id: str, **kwargs: Any) -> None: |
| 1099 | + summary = kwargs.get("summary", "") |
| 1100 | + tokens_before = kwargs.get("tokens_before", 0) |
| 1101 | + tokens_saved = kwargs.get("tokens_saved", 0) |
| 1102 | + events_summarized = kwargs.get("events_summarized", 0) |
| 1103 | + tokens_after = tokens_before - tokens_saved |
| 1104 | + |
| 1105 | + parts: list[RenderableType] = [] |
| 1106 | + if tokens_before and tokens_saved: |
| 1107 | + pct = round((tokens_saved / tokens_before) * 100) |
| 1108 | + parts.append( |
| 1109 | + Text(f"{tokens_before:,} → {tokens_after:,} tokens ({pct}% reduction)", style="dim") |
| 1110 | + ) |
| 1111 | + elif tokens_before: |
| 1112 | + parts.append(Text(f"{tokens_before:,} tokens in context", style="dim")) |
| 1113 | + if events_summarized: |
| 1114 | + parts.append(Text(f"{events_summarized} events summarized", style="dim")) |
| 1115 | + if summary: |
| 1116 | + parts.append(Text("")) |
| 1117 | + parts.append(Markdown(summary)) |
| 1118 | + elif parts: |
| 1119 | + parts.append(Text("")) |
| 1120 | + parts.append( |
| 1121 | + Text("Summary unavailable — structured compaction failed.", style="dim italic") |
| 1122 | + ) |
| 1123 | + |
| 1124 | + if parts: |
| 1125 | + console.print( |
| 1126 | + Panel( |
| 1127 | + Group(*parts), |
| 1128 | + title="[dim blue]Context Compacted[/dim blue]", |
| 1129 | + border_style="dim blue", |
| 1130 | + padding=(0, 1), |
| 1131 | + ) |
| 1132 | + ) |
| 1133 | + else: |
| 1134 | + console.print("[dim blue]Context compacted[/dim blue]") |
1040 | 1135 |
|
1041 | 1136 | if agent_display is not None: |
1042 | 1137 | return HookManager( |
|
0 commit comments