feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
from types import SimpleNamespace
|
2026-04-06 16:30:58 +02:00
|
|
|
|
from unittest.mock import MagicMock, patch
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
|
|
|
|
|
|
from cli import HermesCLI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
|
|
|
|
|
|
cli_obj = HermesCLI.__new__(HermesCLI)
|
|
|
|
|
|
cli_obj.model = model
|
|
|
|
|
|
cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
|
|
|
|
|
|
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
|
|
|
|
|
|
cli_obj.agent = None
|
|
|
|
|
|
return cli_obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attach_agent(
|
|
|
|
|
|
cli_obj,
|
|
|
|
|
|
*,
|
2026-03-17 03:44:44 -07:00
|
|
|
|
input_tokens: int | None = None,
|
|
|
|
|
|
output_tokens: int | None = None,
|
|
|
|
|
|
cache_read_tokens: int = 0,
|
|
|
|
|
|
cache_write_tokens: int = 0,
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
prompt_tokens: int,
|
|
|
|
|
|
completion_tokens: int,
|
|
|
|
|
|
total_tokens: int,
|
|
|
|
|
|
api_calls: int,
|
|
|
|
|
|
context_tokens: int,
|
|
|
|
|
|
context_length: int,
|
|
|
|
|
|
compressions: int = 0,
|
|
|
|
|
|
):
|
|
|
|
|
|
cli_obj.agent = SimpleNamespace(
|
|
|
|
|
|
model=cli_obj.model,
|
2026-03-17 03:44:44 -07:00
|
|
|
|
provider="anthropic" if cli_obj.model.startswith("anthropic/") else None,
|
|
|
|
|
|
base_url="",
|
|
|
|
|
|
session_input_tokens=input_tokens if input_tokens is not None else prompt_tokens,
|
|
|
|
|
|
session_output_tokens=output_tokens if output_tokens is not None else completion_tokens,
|
|
|
|
|
|
session_cache_read_tokens=cache_read_tokens,
|
|
|
|
|
|
session_cache_write_tokens=cache_write_tokens,
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
session_prompt_tokens=prompt_tokens,
|
|
|
|
|
|
session_completion_tokens=completion_tokens,
|
|
|
|
|
|
session_total_tokens=total_tokens,
|
|
|
|
|
|
session_api_calls=api_calls,
|
fix(tests): fix several failing/flaky tests on main (#6777)
* fix(tests): mock is_safe_url in tests that use example.com
Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests.
These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern.
* fix(test): use case-insensitive lookup for model context length check
DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model.
* fix(test): patch is_linux in systemd gateway restart test
The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail.
* fix(test): use non-blocklisted env var in docker forward_env tests
GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work.
* fix(test): fully isolate _has_any_provider_configured from host env
_has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test.
Clear all registry vars and mock get_auth_status so host credentials don't interfere.
* fix(test): correct path to hermes_base_env.py in tool parser tests
Path(__file__).parent.parent resolved to tests/, not the project root.
The file lives at environments/hermes_base_env.py so we need one more parent level.
* fix(test): accept optional HTML fields in Matrix send payload
_send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead.
* fix(test): add config.yaml to codex vision requirements test
The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client.
* fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home
run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail.
* fix(test): add get_rate_limit_state to agent mock in usage report tests
_show_usage now calls agent.get_rate_limit_state() for rate limit
display. The SimpleNamespace mock was missing this method.
* fix(test): update expected Camofox config version from 12 to 13
* fix(test): mock _get_enabled_platforms in nous managed defaults test
Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults
runs twice: the first call sets config values, the second sees them as
already configured and returns an empty set, causing the assertion to
fail.
2026-04-09 17:17:06 -03:00
|
|
|
|
get_rate_limit_state=lambda: None,
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
context_compressor=SimpleNamespace(
|
|
|
|
|
|
last_prompt_tokens=context_tokens,
|
|
|
|
|
|
context_length=context_length,
|
|
|
|
|
|
compression_count=compressions,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
return cli_obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCLIStatusBar:
|
|
|
|
|
|
def test_context_style_thresholds(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._status_bar_context_style(None) == "class:status-bar-dim"
|
|
|
|
|
|
assert cli_obj._status_bar_context_style(10) == "class:status-bar-good"
|
|
|
|
|
|
assert cli_obj._status_bar_context_style(50) == "class:status-bar-warn"
|
|
|
|
|
|
assert cli_obj._status_bar_context_style(81) == "class:status-bar-bad"
|
|
|
|
|
|
assert cli_obj._status_bar_context_style(95) == "class:status-bar-critical"
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_status_bar_text_for_wide_terminal(self):
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(),
|
|
|
|
|
|
prompt_tokens=10_230,
|
|
|
|
|
|
completion_tokens=2_220,
|
|
|
|
|
|
total_tokens=12_450,
|
|
|
|
|
|
api_calls=7,
|
|
|
|
|
|
context_tokens=12_450,
|
|
|
|
|
|
context_length=200_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
text = cli_obj._build_status_bar_text(width=120)
|
|
|
|
|
|
|
|
|
|
|
|
assert "claude-sonnet-4-20250514" in text
|
|
|
|
|
|
assert "12.4K/200K" in text
|
|
|
|
|
|
assert "6%" in text
|
2026-03-16 06:43:57 -07:00
|
|
|
|
assert "$0.06" not in text # cost hidden by default
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
assert "15m" in text
|
|
|
|
|
|
|
2026-04-06 16:30:58 +02:00
|
|
|
|
def test_input_height_counts_wide_characters_using_cell_width(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
class _Doc:
|
|
|
|
|
|
lines = ["你" * 10]
|
|
|
|
|
|
|
|
|
|
|
|
class _Buffer:
|
|
|
|
|
|
document = _Doc()
|
|
|
|
|
|
|
|
|
|
|
|
input_area = SimpleNamespace(buffer=_Buffer())
|
|
|
|
|
|
|
|
|
|
|
|
def _input_height():
|
|
|
|
|
|
try:
|
|
|
|
|
|
from prompt_toolkit.application import get_app
|
|
|
|
|
|
from prompt_toolkit.utils import get_cwidth
|
|
|
|
|
|
|
|
|
|
|
|
doc = input_area.buffer.document
|
|
|
|
|
|
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
|
|
|
|
|
try:
|
|
|
|
|
|
available_width = get_app().output.get_size().columns - prompt_width
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
|
|
|
|
|
if available_width < 10:
|
|
|
|
|
|
available_width = 40
|
|
|
|
|
|
visual_lines = 0
|
|
|
|
|
|
for line in doc.lines:
|
|
|
|
|
|
line_width = get_cwidth(line)
|
|
|
|
|
|
if line_width <= 0:
|
|
|
|
|
|
visual_lines += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
visual_lines += max(1, -(-line_width // available_width))
|
|
|
|
|
|
return min(max(visual_lines, 1), 8)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
mock_app = MagicMock()
|
|
|
|
|
|
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
|
|
|
|
|
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
|
|
|
|
|
patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
|
|
|
|
|
assert _input_height() == 2
|
|
|
|
|
|
|
|
|
|
|
|
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
class _Doc:
|
|
|
|
|
|
lines = ["你" * 10]
|
|
|
|
|
|
|
|
|
|
|
|
class _Buffer:
|
|
|
|
|
|
document = _Doc()
|
|
|
|
|
|
|
|
|
|
|
|
input_area = SimpleNamespace(buffer=_Buffer())
|
|
|
|
|
|
|
|
|
|
|
|
def _input_height():
|
|
|
|
|
|
try:
|
|
|
|
|
|
from prompt_toolkit.application import get_app
|
|
|
|
|
|
from prompt_toolkit.utils import get_cwidth
|
|
|
|
|
|
|
|
|
|
|
|
doc = input_area.buffer.document
|
|
|
|
|
|
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
|
|
|
|
|
try:
|
|
|
|
|
|
available_width = get_app().output.get_size().columns - prompt_width
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
|
|
|
|
|
if available_width < 10:
|
|
|
|
|
|
available_width = 40
|
|
|
|
|
|
visual_lines = 0
|
|
|
|
|
|
for line in doc.lines:
|
|
|
|
|
|
line_width = get_cwidth(line)
|
|
|
|
|
|
if line_width <= 0:
|
|
|
|
|
|
visual_lines += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
visual_lines += max(1, -(-line_width // available_width))
|
|
|
|
|
|
return min(max(visual_lines, 1), 8)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
mock_app = MagicMock()
|
|
|
|
|
|
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
|
|
|
|
|
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
|
|
|
|
|
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
|
|
|
|
|
patch("shutil.get_terminal_size") as mock_shutil:
|
|
|
|
|
|
assert _input_height() == 2
|
|
|
|
|
|
mock_shutil.assert_not_called()
|
|
|
|
|
|
|
2026-03-17 03:44:44 -07:00
|
|
|
|
def test_build_status_bar_text_no_cost_in_status_bar(self):
|
2026-03-16 06:43:57 -07:00
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(),
|
|
|
|
|
|
prompt_tokens=10000,
|
2026-03-17 03:44:44 -07:00
|
|
|
|
completion_tokens=5000,
|
|
|
|
|
|
total_tokens=15000,
|
2026-03-16 06:43:57 -07:00
|
|
|
|
api_calls=7,
|
2026-03-17 03:44:44 -07:00
|
|
|
|
context_tokens=50000,
|
2026-03-16 06:43:57 -07:00
|
|
|
|
context_length=200_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
text = cli_obj._build_status_bar_text(width=120)
|
2026-03-17 03:44:44 -07:00
|
|
|
|
assert "$" not in text # cost is never shown in status bar
|
2026-03-16 06:43:57 -07:00
|
|
|
|
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
def test_build_status_bar_text_collapses_for_narrow_terminal(self):
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(),
|
2026-03-16 06:43:57 -07:00
|
|
|
|
prompt_tokens=10000,
|
|
|
|
|
|
completion_tokens=2400,
|
|
|
|
|
|
total_tokens=12400,
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
api_calls=7,
|
2026-03-16 06:43:57 -07:00
|
|
|
|
context_tokens=12400,
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
context_length=200_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
text = cli_obj._build_status_bar_text(width=60)
|
|
|
|
|
|
|
|
|
|
|
|
assert "⚕" in text
|
2026-03-16 06:43:57 -07:00
|
|
|
|
assert "$0.06" not in text # cost hidden by default
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
assert "15m" in text
|
|
|
|
|
|
assert "200K" not in text
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_status_bar_text_handles_missing_agent(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
text = cli_obj._build_status_bar_text(width=100)
|
|
|
|
|
|
|
|
|
|
|
|
assert "⚕" in text
|
|
|
|
|
|
assert "claude-sonnet-4-20250514" in text
|
|
|
|
|
|
|
2026-04-09 13:02:23 +02:00
|
|
|
|
def test_minimal_tui_chrome_threshold(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._use_minimal_tui_chrome(width=63) is True
|
|
|
|
|
|
assert cli_obj._use_minimal_tui_chrome(width=64) is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_bottom_input_rule_hides_on_narrow_terminals(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._tui_input_rule_height("top", width=50) == 1
|
|
|
|
|
|
assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
|
|
|
|
|
|
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_spacer_reclaimed_on_narrow_terminals(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._agent_running = True
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._agent_spacer_height(width=50) == 0
|
|
|
|
|
|
assert cli_obj._agent_spacer_height(width=90) == 1
|
|
|
|
|
|
cli_obj._agent_running = False
|
|
|
|
|
|
assert cli_obj._agent_spacer_height(width=90) == 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_spinner_line_hidden_on_narrow_terminals(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._spinner_text = "thinking"
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._spinner_widget_height(width=50) == 0
|
|
|
|
|
|
assert cli_obj._spinner_widget_height(width=90) == 1
|
|
|
|
|
|
cli_obj._spinner_text = ""
|
|
|
|
|
|
assert cli_obj._spinner_widget_height(width=90) == 0
|
|
|
|
|
|
|
2026-04-17 22:19:33 -06:00
|
|
|
|
def test_spinner_height_uses_display_width_for_wide_characters(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._spinner_text = "你" * 40
|
|
|
|
|
|
cli_obj._tool_start_time = 0
|
|
|
|
|
|
|
|
|
|
|
|
assert cli_obj._spinner_widget_height(width=64) == 2
|
|
|
|
|
|
|
2026-04-09 14:41:30 +02:00
|
|
|
|
def test_voice_status_bar_compacts_on_narrow_terminals(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = False
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
cli_obj._voice_tts = True
|
|
|
|
|
|
cli_obj._voice_continuous = True
|
|
|
|
|
|
|
|
|
|
|
|
fragments = cli_obj._get_voice_status_fragments(width=50)
|
|
|
|
|
|
|
|
|
|
|
|
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
|
|
|
|
|
|
|
|
|
|
|
|
def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = True
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
|
|
|
|
|
|
fragments = cli_obj._get_voice_status_fragments(width=50)
|
|
|
|
|
|
|
|
|
|
|
|
assert fragments == [("class:voice-status-recording", " ● REC ")]
|
|
|
|
|
|
|
fix(tui): respect voice.record_key config (supersedes #19028, #19339) (#19835)
* fix(tui): respect voice.record_key config instead of hardcoded Ctrl+B
Classic CLI loaded ``voice.record_key`` from config.yaml and bound the
prompt-toolkit handler dynamically (``cli.py`` paths). The new TUI hard-
coded ``Ctrl+B`` everywhere — ``isVoiceToggleKey`` (input handler),
``/voice status`` ("Record key: Ctrl+B"), and ``/voice on`` ("Ctrl+B to
start/stop recording"). A user who set ``voice.record_key: ctrl+o``
(or any other key) saw the documented config silently ignored — only
Ctrl+B worked, the displayed shortcut lied about it.
Wire the configured key end to end through the existing channels:
* **Backend** (``tui_gateway/server.py``): ``voice.toggle`` action=status
AND action=on/off responses now include ``record_key``, sourced from
``config.get('voice', {}).get('record_key', 'ctrl+b')``.
* **Backend types** (``ui-tui/src/gatewayTypes.ts``): ``ConfigFullResponse``
now exposes ``config.voice.record_key`` and ``VoiceToggleResponse``
carries ``record_key`` so the TUI can both bind and display it.
* **Frontend parser/formatter** (``ui-tui/src/lib/platform.ts``):
``parseVoiceRecordKey()`` accepts ``ctrl+b`` / ``alt+r`` / ``cmd+space``
and the common aliases (``option``, ``cmd``, ``win``, …); falls back to
the documented Ctrl+B for empty / multi-character / malformed input so
a typo never silently disables the shortcut. ``formatVoiceRecordKey()``
renders for status text. ``isVoiceToggleKey`` now takes a parsed
``ParsedVoiceRecordKey`` argument; the hardcoded ``ch === 'b'`` is
gone. Default arg keeps existing call sites back-compat.
* **Hydration** (``ui-tui/src/app/useConfigSync.ts``,
``useMainApp.ts``): startup ``config.get full`` already runs; extract
``cfg.voice.record_key`` from it, parse, push into a new
``voiceRecordKey`` state, and forward to the input handler ctx
(``InputHandlerContext.voice.recordKey``). Mtime-poll path also
re-applies the parsed key so a hand-edit of config.yaml takes effect
the next tick — matches existing behaviour for display options.
* **Input handler** (``ui-tui/src/app/useInputHandlers.ts``):
``isVoiceToggleKey(key, ch, voice.recordKey)`` so the configured
binding fires.
* **Slash command** (``ui-tui/src/app/slash/commands/session.ts``):
``/voice status`` and ``/voice on`` use ``formatVoiceRecordKey`` on
the response's ``record_key`` instead of the hardcoded label.
Tests:
* ``parseVoiceRecordKey`` covers ctrl/alt/cmd/super aliases, multi-char
rejection, and empty fallback.
* ``formatVoiceRecordKey`` covers the doc examples (``Ctrl+B``,
``Ctrl+O``, ``Alt+R``, ``Cmd+B``).
* ``isVoiceToggleKey`` regression: ``ctrl+o`` configured → only ``o``
matches, not ``b``; ``alt+r`` matches both alt-bit and meta-bit
encodings (terminal protocol parity); omitted-arg call still binds
Ctrl+B for back-compat.
Full TUI suite (555 tests) passes; ``tsc --noEmit`` clean.
Fixes #18994
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
* fix(tui): support named-key tokens in voice.record_key (space, enter, …)
Reviewer caught that the round-1 parser in #18994 rejected every
multi-character token, so a config value like ``ctrl+space`` (which the
CLI happily binds via prompt_toolkit's ``c-space`` rewrite in
``cli.py``) silently fell back to the documented Ctrl+B default —
re-introducing the same false-shortcut bug the PR was meant to fix,
just at a different surface.
Add explicit named-key support that mirrors what the CLI accepts:
* ``space`` (alias: ``spc``) → matches ``ch === ' '``
* ``enter`` (alias: ``return``, ``ret``) → matches ``key.return``
* ``tab`` → matches ``key.tab``
* ``escape`` (alias: ``esc``) → matches ``key.escape``
* ``backspace`` (alias: ``bs``) → matches ``key.backspace``
* ``delete`` (alias: ``del``) → matches ``key.delete``
``ParsedVoiceRecordKey`` gains an optional ``named`` field; ``ch``
holds either a single char (back-compat) or the canonical named token,
and the runtime matcher dispatches on ``named`` before checking the
modifier shape. Aliases collapse to one canonical name so
``ctrl+esc`` and ``ctrl+escape`` behave identically.
Unrecognised multi-character tokens (e.g. ``ctrl+spcae`` typo, or
unsupported keys like ``ctrl+f5``) still fall back to the Ctrl+B
default rather than silently disabling the binding — keeps the "typo
never silently kills the shortcut" guarantee.
Tests:
* ``parseVoiceRecordKey`` parametrised over every named token + each
alias variant.
* New ``isVoiceToggleKey`` cases for space (ch-based match), enter
(``key.return``), tab, escape, backspace, delete, including
modifier-mismatch negatives.
* ``formatVoiceRecordKey`` renders named keys in title case
(``Ctrl+Space``, ``Ctrl+Enter``).
* Existing fall-back-to-Ctrl+B contract preserved for empty input
AND unrecognised multi-char tokens.
Full TUI suite: 559/559 pass; ``tsc --noEmit`` clean.
Refs #18994 (round-1 review feedback)
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
* test(tui): assert voice.toggle returns configured record_key
Salvage the backend regression from #19339 — asserts ``voice.toggle``
action=on AND action=status responses carry the configured
``voice.record_key`` end-to-end through ``_load_cfg()``. Keeps the
CLI→TUI parity contract visible in the Python test suite alongside
the existing frontend parser/matcher/formatter coverage from #19028.
* fix(tui): address Copilot review on #19835 voice.record_key wiring
Five tightenings on the parser + matcher + hydration surface, all
caught by the Copilot review on the PR — each one turns a silent
false-fire or display/binding skew into a deterministic behaviour.
* **isVoiceToggleKey ctrl branch was too permissive for named keys.**
The doc-default macOS Cmd+B muscle-memory fallback
(``isActionMod(key)`` on top of ``key.ctrl``) fired for every
configured key, so bare Esc — which hermes-ink reports with
``key.meta`` on some macOS terminals — triggered ``ctrl+escape``,
and Alt+Space / Alt+Tab triggered ``ctrl+space`` / ``ctrl+tab``.
Gate the fallback to the literal ``ctrl+b`` binding so any custom
chord requires the real Ctrl bit.
* **Alt branch guarded against Ctrl/Cmd co-press.** Without this,
Ctrl+Alt+<letter> and Cmd+Alt+<letter> also fired ``alt+<letter>``.
* **Dropped the ``meta`` modifier variant and its alias.** In
hermes-ink ``key.meta`` is Alt on xterm-style terminals and Cmd on
legacy macOS ones, so a literal ``meta+b`` config displayed as
``Cmd+B`` while matching Alt+B — exactly the kind of false
shortcut the PR was meant to remove. ``cmd`` / ``command`` now
collapse onto ``super`` (kitty-style ``key.super``, with a macOS
``key.meta`` fallback) and render as ``Cmd+B``. Unknown modifier
tokens fall back to the documented Ctrl+B default rather than
silently coercing to Ctrl.
* **Slash-command display/binding skew.** ``/voice status`` and
``/voice on`` rendered from the fresh gateway ``record_key``
response, but ``useInputHandlers()`` still bound the old key
until the next 5s mtime poll. Thread ``setVoiceRecordKey``
through ``SlashHandlerContext.voice`` and push the parsed spec
into frontend state on every response so text and binding stay
consistent.
* **Test coverage for the two paths Copilot flagged.** Added
vitest coverage for (a) the three-case ``/voice`` slash output
in ``createSlashHandler.test.ts`` and (b) the
``applyDisplay → voice.record_key`` hydration + omit-setter
back-compat paths in ``useConfigSync.test.ts``. Plus regression
cases for every false-fire scenario above.
Suite: 575/575 green, tsc --noEmit clean.
* fix(tui): address Copilot round-2 review on #19835
Three tightenings on the surface introduced in the round-1 fix:
* **``/voice tts`` reset custom bindings to Ctrl+B.** The ``tts`` branch
of ``voice.toggle`` omitted ``record_key`` from its response, so the
frontend's ``r.record_key ?? 'ctrl+b'`` coerced a user's custom
binding back to the default on every TTS toggle. Two-sided fix:
the backend now includes ``record_key`` on the ``tts`` branch (parity
with ``status``/``on``/``off``), and the slash handler only pushes
frontend state when the response actually carries ``record_key`` —
belt-and-suspenders against any future branch forgetting to include
it.
* **``super+b`` / ``win+b`` / ``cmd+b`` displayed "Cmd+B" on Linux and
Windows.** ``formatVoiceRecordKey`` rendered ``mod === 'super'`` as
``Cmd`` universally, which told non-mac users the wrong modifier to
press even though ``isVoiceToggleKey`` matched the right event bits.
Gate the label to ``isMac`` so non-mac renders ``Super+B``.
* **``control+b`` / ``ctrl + b`` lost the macOS Cmd+B fallback.**
``_isDefaultVoiceKey`` keyed off ``parsed.raw`` — so
semantically-equal aliases of the documented default dropped into
the strict branch even though they bind Ctrl+B. Compare on the
parsed spec (mod + ch + named) instead.
Coverage added: Linux ``Super+B`` rendering (and macOS ``Cmd+B``),
``control+b`` / ``ctrl + b`` accepting the Cmd+B fallback on darwin,
``/voice tts`` without ``record_key`` not clobbering cached binding,
and a backend regression asserting every ``voice.toggle`` branch
carries the configured key.
Suite: 579/579 TUI vitest green, 2/2 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-3 review on #19835
Three classes of robustness issue caught on the second pass — all
revolve around malformed YAML tipping ``parseVoiceRecordKey`` or
``_voice_record_key`` into a crash instead of the documented
fallback.
* **Parser crashed on non-string YAML scalars.** ``config.get full``
returns raw ``yaml.safe_load`` output, so ``voice.record_key: 1``
or ``voice.record_key: true`` in a hand-edited config would hit
``.trim()`` on a number/bool and throw, breaking startup and
every mtime re-apply. Accept ``unknown`` at the signature, guard
with ``typeof raw !== 'string'``, and fall back to the default.
* **Backend blew up on non-dict ``voice:``.** Same YAML hazard on
the gateway side: ``voice: true`` / ``voice: cmd+b`` left
``_load_cfg().get("voice")`` as a bool/str, so ``.get("record_key")``
raised AttributeError and took every ``voice.toggle`` branch down
with it. Centralised the lookup in a single
``_voice_record_key()`` helper that ``isinstance``-guards both
``voice`` and ``record_key`` and falls back to ``ctrl+b``.
* **Multi-modifier chords silently dropped extras.** The previous
validator only checked the first modifier token, so ``ctrl+alt+r``
silently parsed as ``ctrl+r`` and ``cmd+ctrl+b`` as ``super+b`` —
a typo bound a different shortcut than the user configured.
Reject multi-modifier spellings outright; the classic CLI only
supports single-modifier bindings via prompt_toolkit's ``c-x`` /
``a-x`` rewrite, so this matches CLI parity.
Coverage added:
* ``parseVoiceRecordKey`` fallback on ``1`` / ``true`` / ``null`` /
``undefined`` / ``{}``.
* ``parseVoiceRecordKey`` fallback on ``ctrl+alt+r`` /
``cmd+ctrl+b`` / ``alt+ctrl+space``.
* ``test_voice_toggle_handles_non_dict_voice_cfg`` exercises
every non-dict ``voice:`` shape (bool, str, None, int, list) and
asserts each falls back to ``record_key: 'ctrl+b'``.
Suite: 581/581 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-4 review on #19835
Four final corners of the voice.record_key surface:
* **Bare-char configs silently coerced to ``ctrl+<key>``.** A config
like ``voice.record_key: o`` / ``space`` / ``escape`` fell through
to the default ``mod = 'ctrl'`` and silently bound Ctrl+O, while
the classic CLI's prompt_toolkit would bind the raw key (no
rewrite) — so the two runtimes silently disagreed on what "o"
means. Require an explicit modifier; bare-char configs fall back
to the documented Ctrl+B default.
* **Reserved ctrl+<letter> bindings would never fire.**
``useInputHandlers()`` intercepts ``ctrl+c`` (interrupt),
``ctrl+d`` (quit), and ``ctrl+l`` (clear screen) before the voice
check runs, so those configs would be advertised in /voice
status but the advertised shortcut never actually triggers
push-to-talk. Added ``_RESERVED_CTRL_CHARS`` at parse time so
the user gets the documented default instead of a dead shortcut.
(``alt+c``, ``cmd+l``, etc. are not intercepted and stay usable.)
* **``_load_cfg()`` root itself may be a non-dict.**
``_voice_record_key()`` isinstance-guarded the ``voice`` subkey
but not the root — a malformed config.yaml that collapsed to a
scalar/list at the top level (``config.yaml: true`` or ``[]``)
would still raise on ``.get("voice")``. Added the top-level
guard too so every malformed shape falls back to ``ctrl+b``.
* **Stale header comment on ``isVoiceToggleKey``.** The doc-comment
still claimed "On macOS we additionally accept the platform
action modifier (Cmd) for the configured letter" even though the
implementation gates the Cmd fallback to the documented default
only. Rewrote to match.
Coverage added:
* ``parseVoiceRecordKey`` fallback on bare chars (``o``, ``b``,
``space``, ``escape``).
* ``parseVoiceRecordKey`` fallback on ``ctrl+c`` / ``ctrl+d`` /
``ctrl+l``; positive case for ``alt+c`` / ``cmd+l`` still usable.
* Backend ``test_voice_toggle_handles_non_dict_voice_cfg`` now
exercises 5 non-dict shapes at the YAML root too.
Suite: 583/583 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-5 review on #19835
Three follow-ups on the voice matcher's modifier + shift discipline:
* **``super`` branch falsely fired on Alt+<key> / bare Esc on macOS.**
``isVoiceToggleKey`` accepted ``isMac && key.meta`` as a Cmd
fallback for the ``super`` modifier — but hermes-ink sets
``key.meta`` for plain Alt/Option AND for bare Escape on some
macOS terminals. A ``cmd+b`` config silently fired on Alt+B;
``cmd+space`` on Alt+Space; ``cmd+escape`` on bare Esc. Drop the
fallback and require the literal ``key.super`` bit. Legacy-
terminal users who need Cmd should upgrade to a kitty-protocol
terminal or bind ``alt+X`` explicitly.
* **Shift bit was never checked.** The parser rejects multi-
modifier configs like ``ctrl+shift+tab``, but the runtime
matcher didn't check ``key.shift`` — so ``ctrl+tab`` also fired
on Ctrl+Shift+Tab and ``alt+enter`` on Alt+Shift+Enter.
Early-return on ``key.shift === true`` so the runtime only fires
the exact chord the user configured.
* **Test leaked ``HERMES_VOICE=1`` into later tests.**
``voice.toggle`` action=on writes to ``os.environ`` directly
(CLI parity, runtime-only flag); ``test_voice_toggle_returns_
configured_record_key`` dispatched action=on without letting
monkeypatch take ownership of the var first. Any later test
that read voice mode in the same Python process could inherit a
stale enabled state. Added ``monkeypatch.setenv("HERMES_VOICE",
"0")`` up front so monkeypatch restores the original value at
teardown.
Coverage added:
* ``cmd+b`` / ``cmd+space`` / ``cmd+escape`` do NOT fire on
``key.meta``-only events on darwin.
* ``ctrl+tab`` / ``alt+enter`` / ``ctrl+o`` reject matches when
``key.shift`` is held; sanity cases without Shift still fire.
Suite: 585/585 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot round-6 review on #19835
Three classes of modifier-discipline tightening + one config-surface
honesty fix:
* **Default ``ctrl+b`` Cmd fallback leaked Alt+B.** The default's
macOS Cmd+B muscle-memory path used ``isActionMod(key)``, which
returns ``key.meta || key.super`` on darwin. hermes-ink also
reports plain Alt as ``key.meta``, so Alt+B silently fired the
default binding. Replaced with strict ``isMac && key.super ===
true`` — kitty-style Cmd+B still works, Alt+B correctly
rejected. Legacy-terminal mac users (Terminal.app without
CSI-u) now get raw Ctrl+B only; the documented default still
works everywhere.
* **ctrl / super branches accepted extra modifier bits.** The
parser rejects multi-modifier configs like ``ctrl+alt+o``, but
the runtime matcher was permissive — ``ctrl+o`` fired on
Ctrl+Alt+O / Ctrl+Cmd+O, and ``super+b`` fired on Cmd+Alt+B /
Ctrl+Cmd+B. Added strict ``!key.alt && !key.meta && key.super
!== true`` on ctrl, and ``!key.ctrl && !key.alt && !key.meta``
on super, so the runtime only fires the exact chord the parser
would let you configure.
* **Dropped ``cmd`` / ``command`` aliases.** They parsed to
``super`` and rendered as ``Cmd+X``, but legacy macOS terminals
report Cmd as ``key.meta`` (same signal as Alt), so a
``cmd+o`` config was advertised as working but never actually
fired on Terminal.app-without-CSI-u. That recreated the
"displayed shortcut does not work" problem this PR was meant to
remove. Users who want the platform action modifier spell it
``super`` / ``win`` — that matches the unambiguous ``key.super``
bit, and kitty-style macOS terminals render it as ``Cmd+X`` via
platform-aware formatter.
Coverage updated:
* Default ctrl+b no longer fires on Alt+B via ``key.meta`` leak;
raw Ctrl+B and kitty-style Cmd+B still fire.
* ``ctrl+o`` rejects Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O chords.
* ``super+b`` rejects Cmd+Alt+B / Cmd+Meta+B / Ctrl+Cmd+B chords.
* ``cmd+b`` / ``command+b`` / ``meta+b`` all fall back to the
documented default at parse time (joined the ambiguous-mac-mod
rejection class).
* Round-2 expectations that asserted ``cmd+b`` parsed as super
and accepted ``key.meta`` on darwin updated to reflect the new
stricter contract.
Suite: 588/588 TUI vitest green, 3/3 backend voice tests green,
tsc --noEmit clean.
* fix(tui): address Copilot follow-up on wire typing + escape precedence
Two follow-ups from the latest Copilot pass:
* **Config wire typing honesty (`gatewayTypes.ts`)**
`config.get full` forwards raw `yaml.safe_load()` output, so
`voice.record_key` can be any scalar/container when hand-edited.
Typing it as `string` suggests a normalized contract that the
backend does not guarantee and makes unsafe callers more likely.
Change `ConfigVoiceConfig.record_key` to `unknown` with an
explicit comment that callers must normalize at runtime.
* **Escape-based voice bindings were swallowed before voice check**
`useInputHandlers()` handled `key.escape` for queue-edit cancel and
selection clear before `isVoiceToggleKey(...)`, so configured
`ctrl+escape` / `alt+escape` / `super+escape` chords were advertised
but never toggled recording in those UI states.
Add an early escape+voice check before generic Esc handlers so
escape-based voice bindings win when configured, while plain Esc
behavior remains unchanged.
Also updated PR #19835 description text to remove stale cmd/command
alias claims and match the current parser contract.
* fix(tui): pass configured voice shortcut through TextInput layer
Thread the live parsed voiceRecordKey into TextInput so configured voice.record_key chords bubble to useInputHandlers instead of being consumed as editor input. This removes the last hardcoded Ctrl+B pass-through in the composer path while preserving existing global control chord behavior.
* fix(tui): require explicit alt bit for escape-based alt chords
Hermes-ink reports bare Escape as meta=true+escape=true on some terminals, so a configured alt+escape binding was firing on bare Esc. Require an explicit key.alt bit when the configured named key is escape so plain Esc stays plain Esc; kitty-style alt+escape still fires.
* fix(tui): harden voice.record + TextInput paste + super-mod reserved list
Three round-7 Copilot follow-ups on #19835:
- voice.record start handler used _load_cfg().get('voice', {}).get(...) without
shape checks, so malformed YAML (bool/scalar/list) returned 5025 instead of
using VAD defaults. Centralized _voice_cfg_dict() helper and type-guarded
silence_threshold/silence_duration with numeric fallbacks.
- TextInput pass-through check moved above paste/copy handling so configured
voice chords (ctrl+v / alt+v / cmd+v) beat the composer's paste/copy
defaults.
- parser now also rejects super+{c,d,l,v} — on macOS those are
copy/exit/clear/paste and would be advertised in /voice status but never
actually toggle recording.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(tui): round-8 Copilot review — allow ctrl+x, gate super reservations to macOS, preserve voice key on transient RPC failure
Three round-8 Copilot follow-ups on #19835:
- Revert ctrl+x addition to _RESERVED_CTRL_CHARS (landed via Copilot Autofix
commit 731ec86): ctrl+x is only claimed during queue-edit
(queueEditIdx !== null), so voice works the rest of the session and
matches CLI ctrl+<letter> parity.
- Gate super+{c,d,l,v} reservation to isMac. Linux/Windows TUI globals key
off Ctrl, so kitty/CSI-u super+<letter> configs don't collide on non-mac
and should stay usable.
- applyDisplay() now skips setVoiceRecordKey when cfg is null so one
transient quietRpc() failure after a config edit doesn't clobber the
cached binding back to Ctrl+B until the next successful poll.
New coverage:
- parseVoiceRecordKey preserves ctrl+x on linux
- super+{c,d,l,v} rejected on darwin, allowed on linux
- applyDisplay(null, ...) leaves voiceRecordKey untouched
* fix(cli,tui): normalize voice.record_key aliases across CLI + TUI for parity
Round-9 Copilot review on #19835: TUI accepted control+/option+/opt+/super+/win+ aliases but the classic CLI only rewrote literal ctrl+/alt+ before handing to prompt_toolkit, so a TUI-valid config silently bound a different (or no) shortcut in the CLI.
- Added normalize_voice_record_key_for_prompt_toolkit() in hermes_cli/voice.py with a single alias table (ctrl/control/alt/option/opt → c-/a-).
- Wired it into all three cli.py sites (_enable_voice_mode hint, _show_voice_status display, and the prompt_toolkit binding in _register_voice_handler).
- /voice status display now renders control+x as Ctrl+X and option+x as Alt+X (canonical casing) to match TUI formatVoiceRecordKey.
- super/win/windows are intentionally left unchanged: prompt_toolkit has no super modifier, so the CLI will reject them loudly at startup rather than silently binding Ctrl+B. Documented this split at both the TUI _MOD_ALIASES comment and the CLI normalizer docstring.
- Added tests covering ctrl/control/alt/option/opt mapping, case-insensitivity, non-string fallback, empty-string fallback, and super/win pass-through.
* fix(cli): port TUI parser contract into CLI voice.record_key normalizer
Round-10 Copilot review on #19835.
hermes_cli/voice.py's normalize_voice_record_key_for_prompt_toolkit() previously did blind substring replacement with no trim/validate step, so the CLI diverged from the TUI parser on:
- whitespace ('ctrl + b' -> 'c- b' instead of 'c-b')
- typoed named keys ('ctrl+spcae' passed through as 'c-spcae' and prompt_toolkit would reject at startup)
- bare-char configs ('o' should fall back, not pass through as 'o')
- multi-modifier chords ('ctrl+alt+r')
- reserved ctrl chars ('ctrl+c/d/l')
- unknown modifiers ('meta+b' / 'shift+b')
- named-key aliases ('return'/'esc'/'bs'/'del' not collapsed to prompt_toolkit canonicals)
Port the TUI parser contract into Python (_VOICE_MOD_ALIASES, _VOICE_NAMED_KEYS, _VOICE_RESERVED_CTRL_CHARS) so one config value binds the same shortcut in both runtimes.
Also added format_voice_record_key_for_status() shared between the PTT hint and /voice status display. Non-string scalars (voice.record_key: true / 1) now surface as 'Ctrl+B' instead of the raw scalar — /voice status no longer advertises a shortcut that can never bind.
Tests: 29/29 in test_voice_wrapper.py, including 11 new regressions covering whitespace, named-key aliases, typos, bare-char, multi-modifier, reserved ctrl, unknown mods, non-string fallback, and formatter contract.
* fix(cli): shape-safe voice config read + graceful super/win fallback
Round-11 Copilot review on #19835.
Two remaining cross-runtime gaps:
1. load_config().get('voice', {}) still assumed voice was a dict, so a hand-edited voice: true / voice: cmd+b at the top level raised AttributeError before the voice UI could start. Added voice_record_key_from_config(cfg) to hermes_cli/voice.py that isinstance-guards both the root and the voice subkey. All three cli.py read sites (_enable_voice_mode hint, _show_voice_status, PTT binding) now use it.
2. The CLI normalizer previously passed super+/win+/windows+ through unrewritten so prompt_toolkit would reject them loudly at startup — but that crash was a worse UX than a silent fallback. Normalizer now returns c-b for those spellings, and the PTT binding site logs a warning so users see why their TUI-only shortcut isn't binding in the CLI.
Coverage: 34/34 in tests/hermes_cli/test_voice_wrapper.py (5 new cases for voice_record_key_from_config + malformed-root + malformed-voice + extractor/normalizer composition).
* fix(cli): self-audit cleanup — remaining voice-config shape safety + doc drift
Self-review of the voice.record_key change set turned up four remaining items Copilot would very likely flag next round:
1. cli.py _voice_start_continuous still read load_config().get('voice', {}).get('silence_threshold') without an isinstance guard, so a hand-edited voice: true / voice: cmd+b (non-dict) raised AttributeError on VAD recording start. Shape-safe coerce the voice dict and numeric-guard silence_threshold/silence_duration.
2. cli.py _enable_voice_mode's auto_tts check had the same bug — fixed with the same isinstance guard.
3. hermes_cli/voice.py module comment on _VOICE_MOD_ALIASES still said super/win/windows 'pass through unchanged and prompt_toolkit's add() call loudly rejects them at startup'. Round 11 changed the normalizer to silently fall back to c-b with a warning at the binding site; updated the comment to match.
4. ui-tui/src/lib/platform.ts header comment had the same stale 'CLI will loudly reject them at startup' claim; updated to 'falls back to the documented default and logs a warning'.
No behavior change on the code paths already covered by test_voice_wrapper.py; the two cli.py fixes are defensive against malformed YAML that previous rounds already hardened in tui_gateway/server.py but missed in the classic CLI.
* fix(cli,tui): round-12 Copilot review — alt-collide on mac, bool-in-int guards, voice UI hardcodes, mtime-reload test
Five round-12 Copilot review items on #19835:
1. platform.ts: hermes-ink reports Alt as key.meta on many terminals; isActionMod on darwin accepts key.meta as the action modifier. So alt+c/d/l get claimed by isCopyShortcut / isAction('d')/'l') before the voice check. Reject those configs at parse time on macOS only (non-mac keeps them usable).
2. cli.py: four remaining hardcoded 'Ctrl+B' sites in voice-facing UI (_get_voice_status_fragments status bar, _voice_start_recording hints, _get_placeholder composer text) were still lying about non-default configs. Added self._voice_record_key_label() shared helper and wired it into all three sites.
3. server.py + cli.py: bool is a subclass of int, so isinstance(silence_threshold, (int, float)) accepted True/False from malformed YAML and forwarded 1/0 to the VAD engine. Exclude bool explicitly so boolean typos fall back to the documented 200 / 3.0 defaults.
4. useConfigSync.ts: extracted the config.get-full fetch+apply body into a shared hydrateFullConfig() helper. Both the initial hydration and mtime-reload paths now use it, so the polling/RPC wiring is exercised by direct unit tests (4 new cases: fresh apply, reapply on new value, transient RPC failure preserves cache, back-compat without voice setter).
5. Added alt+{c,d,l} rejection regressions on darwin + allow on linux, and bool-leak regressions for both silence_threshold and silence_duration in tests/test_tui_gateway_server.py.
Suite: 602/602 TUI vitest, 38/38 backend voice tests, typecheck + lints clean.
* fix(cli): cache voice record-key label at binding time + status-bar coverage
Round-13 Copilot review on #19835.
_voice_record_key_label() was reading live config on every render, which caused two problems:
1. prompt_toolkit registers the push-to-talk binding once at session start (@kb.add(_voice_key)); the binding does NOT re-read config. Editing voice.record_key mid-session would switch the status-bar / placeholder / recording-hint label to the new shortcut while the actual keybinding stayed on the startup chord — reintroducing the display/binding drift this whole PR is fighting.
2. Hot render path: during recording the UI is invalidated every 150ms, so re-loading + deep-merging config on every call added avoidable UI overhead.
Fix: cache the label at the same site that registers the prompt_toolkit binding via new set_voice_record_key_cache(raw_key). _voice_record_key_label() now just returns the cached value (falls back to 'Ctrl+B' before startup). Status/placeholder/hint are always in sync with the live binding; no config reload per render.
Also added 4 regression cases to tests/cli/test_cli_status_bar.py: configured ctrl+<letter> renders in both wide and compact status bars, configured named key (ctrl+space) renders in the recording hint, pre-startup absent cache falls back to Ctrl+B, and malformed configs (bool True) fall through the formatter to Ctrl+B.
Suite: 60/60 test_cli_status_bar + test_voice_wrapper, typecheck + lints clean.
* fix(cli): route /voice on + /voice status through startup-pinned label; mac alt+cdl parity
Round-14 Copilot review on #19835. All three comments legit:
1. _enable_voice_mode still formatted label from live load_config() — mid-session config edit would make /voice on announce the new shortcut while the prompt_toolkit binding stayed the startup chord. Use self._voice_record_key_label() (cached at binding time, round-13) so /voice on cannot drift from the live binding.
2. _show_voice_status had the same bug — /voice status reported live config instead of the pinned startup binding. Fixed the same way.
3. CLI normalizer accepted alt+c/alt+d/alt+l even though the TUI parser rejects them on macOS (Copilot round-12 — hermes-ink reports Alt as key.meta, isActionMod on darwin accepts it, collides with isCopyShortcut / isAction). Added _VOICE_RESERVED_ALT_CHARS_MAC = {c,d,l} gated to sys.platform == 'darwin' so a shared config like option+c falls back to c-b on both runtimes on macOS; non-mac still binds a-c.
Coverage: 4 new tests in test_voice_wrapper.py covering mac alt+cdl rejection, linux alt+cdl allowed, option/opt alias forms, and mac-specific exclusions for other alt letters. 62/62 in voice wrapper + status bar suites.
---------
Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
Co-authored-by: asheriif <ahmedsherif95@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-04 15:49:28 -07:00
|
|
|
|
# Round-13 Copilot review regressions on #19835. The label in voice
|
|
|
|
|
|
# status bar / recording hint / placeholder must render the
|
|
|
|
|
|
# configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
|
|
|
|
|
|
# the cache (``set_voice_record_key_cache``) keeps display in sync
|
|
|
|
|
|
# with the prompt_toolkit binding without re-reading config on
|
|
|
|
|
|
# every render.
|
|
|
|
|
|
def test_voice_status_bar_renders_configured_ctrl_letter(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = False
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
cli_obj._voice_tts = False
|
|
|
|
|
|
cli_obj._voice_continuous = False
|
|
|
|
|
|
cli_obj.set_voice_record_key_cache("ctrl+o")
|
|
|
|
|
|
|
|
|
|
|
|
wide = cli_obj._get_voice_status_fragments(width=120)
|
|
|
|
|
|
assert any("Ctrl+O to record" in text for _cls, text in wide)
|
|
|
|
|
|
|
|
|
|
|
|
compact = cli_obj._get_voice_status_fragments(width=50)
|
|
|
|
|
|
assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]
|
|
|
|
|
|
|
|
|
|
|
|
def test_voice_recording_status_bar_renders_configured_named_key(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = True
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
cli_obj.set_voice_record_key_cache("ctrl+space")
|
|
|
|
|
|
|
|
|
|
|
|
fragments = cli_obj._get_voice_status_fragments(width=120)
|
|
|
|
|
|
|
|
|
|
|
|
assert fragments == [("class:voice-status-recording", " ● REC Ctrl+Space to stop ")]
|
|
|
|
|
|
|
|
|
|
|
|
def test_voice_status_bar_falls_back_to_ctrl_b_without_cache(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = False
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
cli_obj._voice_tts = False
|
|
|
|
|
|
cli_obj._voice_continuous = False
|
|
|
|
|
|
# No cache set — mirrors pre-startup state; fall back to
|
|
|
|
|
|
# documented Ctrl+B default (Copilot round-13 review).
|
|
|
|
|
|
|
|
|
|
|
|
compact = cli_obj._get_voice_status_fragments(width=50)
|
|
|
|
|
|
|
|
|
|
|
|
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
|
|
|
|
|
|
|
|
|
|
|
|
def test_voice_status_bar_renders_malformed_config_as_default(self):
|
|
|
|
|
|
cli_obj = _make_cli()
|
|
|
|
|
|
cli_obj._voice_mode = True
|
|
|
|
|
|
cli_obj._voice_recording = False
|
|
|
|
|
|
cli_obj._voice_processing = False
|
|
|
|
|
|
cli_obj._voice_tts = False
|
|
|
|
|
|
cli_obj._voice_continuous = False
|
|
|
|
|
|
# Non-string / typoed configs fall through the formatter to the
|
|
|
|
|
|
# documented default so the status bar never advertises an
|
|
|
|
|
|
# invalid shortcut.
|
|
|
|
|
|
cli_obj.set_voice_record_key_cache(True)
|
|
|
|
|
|
|
|
|
|
|
|
compact = cli_obj._get_voice_status_fragments(width=50)
|
|
|
|
|
|
|
|
|
|
|
|
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
|
|
|
|
|
|
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
|
|
|
|
|
|
class TestCLIUsageReport:
|
|
|
|
|
|
def test_show_usage_includes_estimated_cost(self, capsys):
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(),
|
|
|
|
|
|
prompt_tokens=10_230,
|
|
|
|
|
|
completion_tokens=2_220,
|
|
|
|
|
|
total_tokens=12_450,
|
|
|
|
|
|
api_calls=7,
|
|
|
|
|
|
context_tokens=12_450,
|
|
|
|
|
|
context_length=200_000,
|
|
|
|
|
|
compressions=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
cli_obj.verbose = False
|
|
|
|
|
|
|
|
|
|
|
|
cli_obj._show_usage()
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
assert "Model:" in output
|
2026-03-17 03:44:44 -07:00
|
|
|
|
assert "Cost status:" in output
|
|
|
|
|
|
assert "Cost source:" in output
|
feat: add persistent CLI status bar and usage details (#1522)
Salvaged from PR #1104 by kshitijk4poor. Closes #683.
Adds a persistent status bar to the CLI showing model name, context
window usage with visual bar, estimated cost, and session duration.
Responsive layout degrades gracefully for narrow terminals.
Changes:
- agent/usage_pricing.py: shared pricing table, cost estimation with
Decimal arithmetic, duration/token formatting helpers
- agent/insights.py: refactored to reuse usage_pricing (eliminates
duplicate pricing table and formatting logic)
- cli.py: status bar with FormattedTextControl fragments, color-coded
context thresholds (green/yellow/orange/red), enhanced /usage with
cost breakdown, 1Hz idle refresh for status bar updates
- tests/test_cli_status_bar.py: status bar snapshot, width collapsing,
usage report with/without pricing, zero-priced model handling
- tests/test_insights.py: verify zero-priced providers show as unknown
Salvage fixes:
- Resolved conflict with voice status bar (both coexist in layout)
- Import _format_context_length from hermes_cli.banner (moved since PR)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-03-16 04:42:48 -07:00
|
|
|
|
assert "Total cost:" in output
|
|
|
|
|
|
assert "$" in output
|
|
|
|
|
|
assert "0.064" in output
|
|
|
|
|
|
assert "Session duration:" in output
|
|
|
|
|
|
assert "Compressions:" in output
|
|
|
|
|
|
|
|
|
|
|
|
def test_show_usage_marks_unknown_pricing(self, capsys):
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(model="local/my-custom-model"),
|
|
|
|
|
|
prompt_tokens=1_000,
|
|
|
|
|
|
completion_tokens=500,
|
|
|
|
|
|
total_tokens=1_500,
|
|
|
|
|
|
api_calls=1,
|
|
|
|
|
|
context_tokens=1_000,
|
|
|
|
|
|
context_length=32_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
cli_obj.verbose = False
|
|
|
|
|
|
|
|
|
|
|
|
cli_obj._show_usage()
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
assert "Total cost:" in output
|
|
|
|
|
|
assert "n/a" in output
|
|
|
|
|
|
assert "Pricing unknown for local/my-custom-model" in output
|
|
|
|
|
|
|
|
|
|
|
|
def test_zero_priced_provider_models_stay_unknown(self, capsys):
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(model="glm-5"),
|
|
|
|
|
|
prompt_tokens=1_000,
|
|
|
|
|
|
completion_tokens=500,
|
|
|
|
|
|
total_tokens=1_500,
|
|
|
|
|
|
api_calls=1,
|
|
|
|
|
|
context_tokens=1_000,
|
|
|
|
|
|
context_length=32_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
cli_obj.verbose = False
|
|
|
|
|
|
|
|
|
|
|
|
cli_obj._show_usage()
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
assert "Total cost:" in output
|
|
|
|
|
|
assert "n/a" in output
|
|
|
|
|
|
assert "Pricing unknown for glm-5" in output
|
2026-03-26 17:33:11 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStatusBarWidthSource:
|
|
|
|
|
|
"""Ensure status bar fragments don't overflow the terminal width."""
|
|
|
|
|
|
|
|
|
|
|
|
def _make_wide_cli(self):
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
cli_obj = _attach_agent(
|
|
|
|
|
|
_make_cli(),
|
|
|
|
|
|
prompt_tokens=100_000,
|
|
|
|
|
|
completion_tokens=5_000,
|
|
|
|
|
|
total_tokens=105_000,
|
|
|
|
|
|
api_calls=20,
|
|
|
|
|
|
context_tokens=100_000,
|
|
|
|
|
|
context_length=200_000,
|
|
|
|
|
|
)
|
|
|
|
|
|
cli_obj._status_bar_visible = True
|
|
|
|
|
|
return cli_obj
|
|
|
|
|
|
|
|
|
|
|
|
def test_fragments_fit_within_announced_width(self):
|
|
|
|
|
|
"""Total fragment text length must not exceed the width used to build them."""
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
cli_obj = self._make_wide_cli()
|
|
|
|
|
|
|
|
|
|
|
|
for width in (40, 52, 76, 80, 120, 200):
|
|
|
|
|
|
mock_app = MagicMock()
|
|
|
|
|
|
mock_app.output.get_size.return_value = MagicMock(columns=width)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
|
|
|
|
|
frags = cli_obj._get_status_bar_fragments()
|
|
|
|
|
|
|
|
|
|
|
|
total_text = "".join(text for _, text in frags)
|
2026-03-30 12:29:07 +05:30
|
|
|
|
display_width = cli_obj._status_bar_display_width(total_text)
|
|
|
|
|
|
assert display_width <= width + 4, ( # +4 for minor padding chars
|
|
|
|
|
|
f"At width={width}, fragment total {display_width} cells overflows "
|
2026-03-26 17:33:11 -07:00
|
|
|
|
f"({total_text!r})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_fragments_use_pt_width_over_shutil(self):
|
|
|
|
|
|
"""When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
cli_obj = self._make_wide_cli()
|
|
|
|
|
|
|
|
|
|
|
|
mock_app = MagicMock()
|
|
|
|
|
|
mock_app.output.get_size.return_value = MagicMock(columns=120)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
|
|
|
|
|
|
patch("shutil.get_terminal_size") as mock_shutil:
|
|
|
|
|
|
cli_obj._get_status_bar_fragments()
|
|
|
|
|
|
|
|
|
|
|
|
mock_shutil.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
def test_fragments_fall_back_to_shutil_when_no_app(self):
|
|
|
|
|
|
"""Outside a TUI context (no running app), shutil must be used as fallback."""
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
cli_obj = self._make_wide_cli()
|
|
|
|
|
|
|
|
|
|
|
|
with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \
|
|
|
|
|
|
patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil:
|
|
|
|
|
|
frags = cli_obj._get_status_bar_fragments()
|
|
|
|
|
|
|
|
|
|
|
|
mock_shutil.assert_called()
|
|
|
|
|
|
assert len(frags) > 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_status_bar_text_uses_pt_width(self):
|
|
|
|
|
|
"""_build_status_bar_text() must also prefer prompt_toolkit width."""
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
cli_obj = self._make_wide_cli()
|
|
|
|
|
|
|
|
|
|
|
|
mock_app = MagicMock()
|
|
|
|
|
|
mock_app.output.get_size.return_value = MagicMock(columns=80)
|
|
|
|
|
|
|
|
|
|
|
|
with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
|
|
|
|
|
patch("shutil.get_terminal_size") as mock_shutil:
|
|
|
|
|
|
text = cli_obj._build_status_bar_text() # no explicit width
|
|
|
|
|
|
|
|
|
|
|
|
mock_shutil.assert_not_called()
|
|
|
|
|
|
assert isinstance(text, str)
|
|
|
|
|
|
assert len(text) > 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_width_skips_pt_lookup(self):
|
|
|
|
|
|
"""An explicit width= argument must bypass both PT and shutil lookups."""
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
cli_obj = self._make_wide_cli()
|
|
|
|
|
|
|
|
|
|
|
|
with patch("prompt_toolkit.application.get_app") as mock_get_app, \
|
|
|
|
|
|
patch("shutil.get_terminal_size") as mock_shutil:
|
|
|
|
|
|
text = cli_obj._build_status_bar_text(width=100)
|
|
|
|
|
|
|
|
|
|
|
|
mock_get_app.assert_not_called()
|
|
|
|
|
|
mock_shutil.assert_not_called()
|
|
|
|
|
|
assert len(text) > 0
|