* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
1250 lines
58 KiB
Python
1250 lines
58 KiB
Python
"""Status bar, spinner, turn-summary, pet pane, and prompt-stash rendering for the
|
|
interactive CLI.
|
|
|
|
Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY
|
|
inside each method (``from cli import ...``) — never at module load time (import cycle).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import shutil
|
|
import threading
|
|
import time
|
|
|
|
from agent.i18n import t
|
|
from agent.pet import render as pet_render
|
|
from hermes_cli.banner import _format_context_length
|
|
from typing import Any, Dict, Optional
|
|
|
|
_SB = "class:status-bar"
|
|
_DIM = "class:status-bar-dim"
|
|
_STRONG = "class:status-bar-strong"
|
|
_AGENT_COUNTERS = (
|
|
"session_input_tokens", "session_output_tokens", "session_cache_read_tokens",
|
|
"session_cache_write_tokens", "session_prompt_tokens", "session_completion_tokens",
|
|
"session_total_tokens", "session_api_calls")
|
|
|
|
|
|
def _threshold_style(value, ladder, fallback: str) -> str:
|
|
"""First ``class:status-bar-<name>`` whose ``value >= bound`` in a descending ladder."""
|
|
for bound, name in ladder:
|
|
if value >= bound:
|
|
return f"class:status-bar-{name}"
|
|
return f"class:status-bar-{fallback}"
|
|
|
|
|
|
def _finite(v):
|
|
"""Drop NaN / negative / absurd provider timings (e.g. -0.8s seen in logs)."""
|
|
return None if v is None or v != v or v < 0 or v > 1e6 else v
|
|
|
|
|
|
class CLIStatusBarMixin:
|
|
"""Status bar, spinner, turn-summary, pet pane, and prompt-stash rendering for the
|
|
interactive CLI."""
|
|
|
|
def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
|
|
if percent_used is None:
|
|
return _DIM
|
|
if percent_used >= 95:
|
|
return "class:status-bar-critical"
|
|
if percent_used > 80:
|
|
return "class:status-bar-bad"
|
|
return _threshold_style(percent_used, ((50, "warn"),), "good")
|
|
|
|
def _cache_hit_rate(self, snapshot: dict, precision: int = 1) -> "tuple[float, str] | None":
|
|
"""Return (cache_pct, label) or None without cache data. Prefers the baseline-delta pct
|
|
from ``_get_status_bar_snapshot`` (resets on model switch / compression, so it reflects
|
|
the *current* cache regime); falls back to the session-lifetime ratio."""
|
|
delta_pct = snapshot.get("cache_hit_pct")
|
|
if delta_pct is not None:
|
|
return float(delta_pct), f"◎ {float(delta_pct):.{precision}f}%"
|
|
cache_read = snapshot.get("session_cache_read_tokens", 0)
|
|
prompt_total = snapshot.get("session_prompt_tokens", 0)
|
|
if cache_read > 0 and prompt_total > 0:
|
|
cache_pct = cache_read / prompt_total * 100
|
|
return cache_pct, f"◎ {cache_pct:.{precision}f}%"
|
|
return None
|
|
|
|
def _cache_hit_rate_style(self, cache_pct: float) -> str:
|
|
"""Higher is better (opposite of context %)."""
|
|
return _threshold_style(cache_pct, ((70, "good"), (40, "warn")), "bad")
|
|
|
|
@staticmethod
|
|
def _battery_status_style(category: str) -> str:
|
|
return {
|
|
"good": "class:status-bar-good",
|
|
"warn": "class:status-bar-warn",
|
|
"bad": "class:status-bar-bad",
|
|
"critical": "class:status-bar-critical",
|
|
}.get(category, _DIM)
|
|
|
|
def _vim_mode_label(self) -> str:
|
|
"""Current vi editing mode as a short status-bar label; empty when vim mode is off
|
|
or the application is not running yet."""
|
|
if not getattr(self, "_vim_mode", False):
|
|
return ""
|
|
try:
|
|
from prompt_toolkit.key_binding.vi_state import InputMode
|
|
app = getattr(self, "_app", None)
|
|
if app is None:
|
|
return ""
|
|
mode = app.vi_state.input_mode
|
|
if mode in (InputMode.INSERT, InputMode.INSERT_MULTIPLE):
|
|
return t("cli.status_bar.vim_insert")
|
|
if mode == InputMode.REPLACE:
|
|
return t("cli.status_bar.vim_replace")
|
|
return t("cli.status_bar.vim_normal")
|
|
except Exception:
|
|
return ""
|
|
|
|
def _handle_battery_command(self, cmd_original: str) -> None:
|
|
"""``/battery`` toggles, ``/battery on|off`` sets, ``/battery status`` reports the
|
|
setting plus a live reading. Persisted to ``display.battery``."""
|
|
from cli import save_config_value
|
|
parts = (cmd_original or "").split()
|
|
arg = parts[1].strip().lower() if len(parts) > 1 else ""
|
|
|
|
try:
|
|
from agent.battery import format_battery, read_battery
|
|
reading = read_battery(use_cache=False)
|
|
except Exception:
|
|
reading = None
|
|
|
|
def _detail(no_battery: str) -> str:
|
|
if reading is None:
|
|
return ""
|
|
return f" — {format_battery(reading)}" if reading.available else f" — {no_battery}"
|
|
|
|
if arg in ("status", "show"):
|
|
state = t("cli.shared.label_on") if self._battery_visible else t("cli.shared.label_off")
|
|
detail = _detail(t("cli.status_bar.battery_none"))
|
|
if reading is not None and reading.available:
|
|
detail = t("cli.status_bar.battery_currently", reading=format_battery(reading))
|
|
self._console_print(f" {t('cli.status_bar.battery_state', state=state, detail=detail)}")
|
|
return
|
|
|
|
if arg in ("on", "true", "yes"):
|
|
target = True
|
|
elif arg in ("off", "false", "no"):
|
|
target = False
|
|
elif arg in ("", "toggle"):
|
|
target = not self._battery_visible
|
|
else:
|
|
self._console_print(f" {t('cli.status_bar.battery_usage')}")
|
|
return
|
|
|
|
self._battery_visible = target
|
|
save_config_value("display.battery", target)
|
|
if target:
|
|
self._console_print(
|
|
f" {t('cli.status_bar.battery_on', detail=_detail(t('cli.status_bar.battery_none_nothing_shown')))}"
|
|
)
|
|
else:
|
|
self._console_print(f" {t('cli.status_bar.battery_off')}")
|
|
|
|
@staticmethod
|
|
def _compression_count_style(count: int) -> str:
|
|
return _threshold_style(count, ((10, "bad"), (5, "warn")), "dim")
|
|
|
|
def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> str:
|
|
safe_percent = max(0, min(100, percent_used or 0))
|
|
filled = round((safe_percent / 100) * width)
|
|
return f"[{('█' * filled) + ('░' * max(0, width - filled))}]"
|
|
|
|
@staticmethod
|
|
def _format_prompt_elapsed(
|
|
prompt_start_time: Optional[float], prompt_duration: float, live: bool = False) -> str:
|
|
"""Per-prompt elapsed time. Always a string (``⏲ 0s`` on fresh start); seconds stay
|
|
visible at every scale so it increments smoothly (``1m 59s → 2m → 2m 1s``). ⏱ while
|
|
live, ⏲ frozen — width-1 glyphs (no variation selector) keep the bar aligned."""
|
|
if prompt_start_time is None and prompt_duration == 0.0:
|
|
return "⏲ 0s"
|
|
if prompt_start_time is not None:
|
|
elapsed = max(0.0, time.time() - prompt_start_time)
|
|
else:
|
|
elapsed = max(0.0, prompt_duration)
|
|
days, remaining = divmod(elapsed, 86400)
|
|
hours, remaining = divmod(remaining, 3600)
|
|
minutes, seconds = int(remaining // 60), int(remaining % 60)
|
|
days, hours = int(days), int(hours)
|
|
if days > 0:
|
|
time_str = t("cli.shared.duration_dhm", days=days, hours=hours, minutes=minutes)
|
|
elif hours > 0:
|
|
time_str = (t("cli.shared.duration_hms", hours=hours, minutes=minutes, seconds=seconds) if seconds
|
|
else t("cli.shared.duration_hm", hours=hours, minutes=minutes))
|
|
elif minutes > 0:
|
|
time_str = (t("cli.shared.duration_ms", minutes=minutes, seconds=seconds) if seconds
|
|
else t("cli.shared.duration_m", minutes=minutes))
|
|
else:
|
|
time_str = t("cli.shared.duration_s", seconds=int(elapsed))
|
|
return f"{'⏱' if live else '⏲'} {time_str}"
|
|
|
|
@staticmethod
|
|
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
|
|
"""``✓ 42s`` since the last final response; empty while a turn is live or before the
|
|
first turn completes."""
|
|
from cli import format_duration_compact
|
|
if turn_live or last_finished_at is None:
|
|
return ""
|
|
return f"✓ {format_duration_compact(max(0.0, time.time() - last_finished_at))}"
|
|
|
|
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
|
from cli import _reverse_alias_for_display, datetime, format_duration_compact
|
|
agent = getattr(self, "agent", None)
|
|
# Prefer the agent's model name — it updates on fallback; self.model never changes.
|
|
model_name = (getattr(agent, "model", None) or self.model or t("cli.shared.unknown"))
|
|
# Friendly display: reverse-alias from config ``model_aliases:`` first (turns long
|
|
# Palantir RIDs into the user's short name), else slash/length truncation.
|
|
model_short = _reverse_alias_for_display(model_name)
|
|
if model_short == model_name:
|
|
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
|
|
# Shared RID-prefix stripper so this and ModelSwitchResult can't drift.
|
|
from hermes_cli.model_switch import format_model_for_display
|
|
model_short = format_model_for_display(model_short)
|
|
if model_short.endswith(".gguf"):
|
|
model_short = model_short[:-5]
|
|
if len(model_short) > 26:
|
|
model_short = f"{model_short[:23]}..."
|
|
|
|
prompt_start = getattr(self, "_prompt_start_time", None)
|
|
turn_live = prompt_start is not None
|
|
elapsed_seconds = max(0.0, (datetime.now() - self.session_start).total_seconds())
|
|
snapshot = {
|
|
"model_name": model_name,
|
|
"model_short": model_short,
|
|
"duration": format_duration_compact(elapsed_seconds),
|
|
"session_title": self._get_status_bar_session_title(),
|
|
"prompt_elapsed": self._format_prompt_elapsed(
|
|
prompt_start, getattr(self, "_prompt_duration", 0.0), live=turn_live),
|
|
"idle_since": self._format_idle_since(
|
|
getattr(self, "_last_turn_finished_at", None), turn_live=turn_live),
|
|
"context_tokens": 0,
|
|
"context_length": None,
|
|
"context_percent": None,
|
|
**dict.fromkeys(_AGENT_COUNTERS, 0),
|
|
"compressions": 0,
|
|
"active_background_tasks": 0,
|
|
"active_background_processes": 0,
|
|
"active_background_subagents": 0,
|
|
"battery_label": "",
|
|
"battery_category": "dim",
|
|
"focus_label": "", # /focus badge: the reduced-output mode is never invisible.
|
|
"git_branch": "",
|
|
"goal_active": False,
|
|
"goal_turns_used": 0,
|
|
"goal_max_turns": 0}
|
|
|
|
try:
|
|
from hermes_cli.focus_view import focus_statusbar_segment
|
|
|
|
snapshot["focus_label"] = focus_statusbar_segment(
|
|
bool(getattr(self, "_focus_view_enabled", False)))
|
|
except Exception:
|
|
pass
|
|
|
|
# Git branch (⎇) — opt-in via display.status_bar.fields, so the filesystem probe
|
|
# (TTL-cached in status_bar_git) only runs when the user asked for the segment.
|
|
try:
|
|
_fields = self._get_status_bar_field_set()
|
|
if _fields is not None and "git_branch" in _fields:
|
|
from hermes_cli.status_bar_git import current_git_branch
|
|
|
|
snapshot["git_branch"] = current_git_branch()
|
|
except Exception:
|
|
pass
|
|
|
|
# Battery reads are memoised inside agent.battery, so per-repaint polling is cheap.
|
|
if getattr(self, "_battery_visible", False):
|
|
try:
|
|
from agent.battery import battery_category, format_battery, read_battery
|
|
|
|
_batt = read_battery()
|
|
snapshot["battery_label"] = format_battery(_batt)
|
|
snapshot["battery_category"] = battery_category(_batt)
|
|
except Exception:
|
|
pass
|
|
|
|
# Live /bg tasks: entries are removed in the task thread's finally block; dict len()
|
|
# is atomic in CPython, no lock needed.
|
|
try:
|
|
bg_tasks = getattr(self, "_background_tasks", None)
|
|
if bg_tasks:
|
|
snapshot["active_background_tasks"] = len(bg_tasks)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from tools.process_registry import process_registry
|
|
snapshot["active_background_processes"] = process_registry.count_running()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from tools.async_delegation import active_count as _async_active_count
|
|
snapshot["active_background_subagents"] = _async_active_count()
|
|
except Exception:
|
|
pass
|
|
|
|
# Standing /goal (Ralph loop): GoalManager is cached on self — no DB hit per repaint.
|
|
# Only an *active* goal earns a segment (paused/done stay out, like the desktop).
|
|
try:
|
|
goal_mgr = self._get_goal_manager()
|
|
if goal_mgr is not None and goal_mgr.is_active():
|
|
goal_state = goal_mgr.state
|
|
snapshot["goal_active"] = True
|
|
snapshot["goal_turns_used"] = int(getattr(goal_state, "turns_used", 0) or 0)
|
|
snapshot["goal_max_turns"] = int(getattr(goal_state, "max_turns", 0) or 0)
|
|
except Exception:
|
|
pass
|
|
|
|
if not agent:
|
|
return snapshot
|
|
|
|
for key in _AGENT_COUNTERS:
|
|
snapshot[key] = getattr(agent, key, 0) or 0
|
|
|
|
compressor = getattr(agent, "context_compressor", None)
|
|
if compressor:
|
|
# last_prompt_tokens parks at the -1 sentinel right after a compression until the
|
|
# next real API call; clamp so the bar never renders "-1/200K".
|
|
context_tokens = max(0, getattr(compressor, "last_prompt_tokens", 0) or 0)
|
|
from agent.context_breakdown import context_display_source
|
|
snapshot["context_estimated"] = context_display_source(compressor) != "provider_usage"
|
|
# Display-only anchoring: on reasoning models a long tool loop replays the turn's
|
|
# thinking on every request, so the LAST request's prompt_tokens can exceed the
|
|
# durable transcript by hundreds of K and the bar sawtooths at the turn boundary.
|
|
# Anchor on the turn's FIRST response plus a delta estimate of appended messages.
|
|
# The compression trigger keeps using real last-request usage.
|
|
try:
|
|
from agent.usage_anchor import anchored_context_tokens
|
|
|
|
_msgs = getattr(agent, "_session_messages", None)
|
|
_anchored = anchored_context_tokens(
|
|
_msgs if isinstance(_msgs, list) else [],
|
|
getattr(agent, "_turn_base_usage_anchor", None),
|
|
charge_stale_thinking=False)
|
|
if _anchored is not None and _anchored > 0:
|
|
context_tokens = _anchored
|
|
anchor = agent._turn_base_usage_anchor
|
|
delta = _msgs[int(anchor["base_count"]):]
|
|
if delta and delta[0].get("role") == "assistant":
|
|
delta = delta[1:]
|
|
snapshot["context_estimated"] = bool(delta)
|
|
except Exception:
|
|
pass
|
|
context_length = max(0, getattr(compressor, "context_length", 0) or 0)
|
|
if context_length:
|
|
context_tokens = min(context_tokens, context_length)
|
|
snapshot["context_tokens"] = context_tokens
|
|
snapshot["context_length"] = context_length or None
|
|
from agent.context_pin import is_context_pinned
|
|
snapshot["context_pinned"] = is_context_pinned(
|
|
context_length, getattr(compressor, "_config_context_length", None))
|
|
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
|
|
if context_length:
|
|
pct = round((context_tokens / context_length) * 100)
|
|
snapshot["context_percent"] = max(0, min(100, pct))
|
|
|
|
# Cache-hit ratio since the last baseline reset (model switch and compression both
|
|
# invalidate the prompt cache). hit = cache_read / prompt_tokens, where
|
|
# prompt = input + cache_read + cache_write (CanonicalUsage).
|
|
pct = None
|
|
try:
|
|
base_model = getattr(self, "_cache_hit_baseline_model", None)
|
|
base_prompt = int(getattr(self, "_cache_hit_baseline_prompt", 0) or 0)
|
|
base_read = int(getattr(self, "_cache_hit_baseline_read", 0) or 0)
|
|
base_comps = int(getattr(self, "_cache_hit_baseline_compressions", 0) or 0)
|
|
cur_comps = int(snapshot["compressions"] or 0)
|
|
cur_prompt = int(snapshot["session_prompt_tokens"] or 0)
|
|
cur_read = int(snapshot["session_cache_read_tokens"] or 0)
|
|
|
|
def _rebase(*, tokens: bool) -> None:
|
|
nonlocal base_prompt, base_read, base_comps
|
|
self._cache_hit_baseline_model = model_name
|
|
self._cache_hit_baseline_compressions = base_comps = cur_comps
|
|
if tokens:
|
|
self._cache_hit_baseline_prompt = base_prompt = cur_prompt
|
|
self._cache_hit_baseline_read = base_read = cur_read
|
|
|
|
if base_model is None:
|
|
_rebase(tokens=False)
|
|
elif model_name != base_model:
|
|
_rebase(tokens=True)
|
|
if cur_comps != base_comps:
|
|
_rebase(tokens=True)
|
|
delta_prompt = cur_prompt - base_prompt
|
|
delta_read = cur_read - base_read
|
|
# A zero-read regime hides the segment (no data ≠ an alarming 0%); pct stays a
|
|
# float so renderers choose their own precision.
|
|
if delta_prompt > 0 and delta_read > 0:
|
|
pct = max(0.0, min(100.0, (delta_read / delta_prompt) * 100))
|
|
except Exception:
|
|
pct = None
|
|
snapshot["cache_hit_pct"] = pct
|
|
snapshot["cache_hit_label"] = f"{pct:.0f}%" if pct is not None else ""
|
|
|
|
# Rolling avg latency / velocity over the deques kept by agent/conversation_loop.py
|
|
# (hidden on Codex app-server, which reports no latency).
|
|
avg_lat = avg_vel = None
|
|
try:
|
|
lhist = list(getattr(agent, "_api_latency_history", []) or [])
|
|
ohist = list(getattr(agent, "_api_output_history", []) or [])
|
|
n = min(len(lhist), len(ohist)) # appended together; keep aligned
|
|
if n:
|
|
lhist, ohist = lhist[-n:], ohist[-n:]
|
|
total_lat = sum(lhist)
|
|
# Mean for latency; sum/sum for velocity (true throughput, not mean of ratios).
|
|
avg_lat = _finite(total_lat / n)
|
|
avg_vel = _finite(sum(ohist) / total_lat if total_lat > 0 else None)
|
|
except Exception:
|
|
avg_lat = avg_vel = None
|
|
snapshot["avg_latency"] = float(avg_lat) if avg_lat is not None else None
|
|
snapshot["avg_latency_label"] = t("cli.status_bar.latency_label", value=f"{avg_lat:.1f}") if avg_lat is not None else ""
|
|
snapshot["avg_velocity"] = float(avg_vel) if avg_vel is not None else None
|
|
snapshot["avg_velocity_label"] = t("cli.status_bar.velocity_label", value=f"{avg_vel:.0f}") if avg_vel is not None else ""
|
|
return snapshot
|
|
|
|
def _get_status_bar_session_title(self) -> str:
|
|
"""Current title, polling state.db at most every 1.5s (not on every repaint)."""
|
|
pending = str(getattr(self, "_pending_title", None) or "").strip()
|
|
session_id = str(getattr(self, "session_id", "") or "")
|
|
now = time.monotonic()
|
|
checked_at = float(getattr(self, "_status_bar_title_checked_at", 0.0) or 0.0)
|
|
cache_fresh = (
|
|
getattr(self, "_status_bar_title_session_id", None) == session_id and now - checked_at < 1.5
|
|
)
|
|
if not pending and cache_fresh:
|
|
return str(getattr(self, "_status_bar_title_cache", "") or "")
|
|
title = pending
|
|
db = getattr(self, "_session_db", None)
|
|
if not pending and db is not None and session_id:
|
|
try:
|
|
title = str(db.get_session_title(session_id) or "").strip()
|
|
except Exception:
|
|
title = ""
|
|
self._status_bar_title_session_id = session_id
|
|
self._status_bar_title_cache = title
|
|
self._status_bar_title_checked_at = now
|
|
return title
|
|
|
|
@staticmethod
|
|
def _status_bar_display_width(text: str) -> int:
|
|
"""Terminal cell width (some glyphs render wider than one codepoint); keeps the bar
|
|
from wrapping onto a second line and leaving duplicate rows."""
|
|
try:
|
|
from prompt_toolkit.utils import get_cwidth
|
|
return get_cwidth(text or "")
|
|
except Exception:
|
|
return len(text or "")
|
|
|
|
@classmethod
|
|
def _trim_status_bar_text(cls, text: str, max_width: int) -> str:
|
|
"""Trim status-bar text to a single terminal row."""
|
|
if max_width <= 0:
|
|
return ""
|
|
cw = cls._status_bar_display_width
|
|
if cw(text) <= max_width:
|
|
return text
|
|
ellipsis_width = cw("...")
|
|
if max_width <= ellipsis_width:
|
|
return "..."[:max_width]
|
|
out, width = [], 0
|
|
for ch in text:
|
|
if width + cw(ch) + ellipsis_width > max_width:
|
|
break
|
|
out.append(ch)
|
|
width += cw(ch)
|
|
return "".join(out).rstrip() + "..."
|
|
|
|
@classmethod
|
|
def _status_title_badge(cls, title: str, width: int) -> "tuple[str, int] | None":
|
|
"""(badge, left_width) for the far-right session-title badge, or None when it
|
|
doesn't fit (no title / bar narrower than 24 cells)."""
|
|
title = str(title or "").strip()
|
|
if not title or width < 24:
|
|
return None
|
|
title_width = max(6, min(30, width // 3))
|
|
badge = f" {cls._trim_status_bar_text(title, title_width - 2)} "
|
|
suffix_width = cls._status_bar_display_width(" ─") + cls._status_bar_display_width(badge)
|
|
return badge, max(0, width - suffix_width)
|
|
|
|
@classmethod
|
|
def _right_align_status_title(cls, text: str, title: str, width: int) -> str:
|
|
"""Pin a bounded session-title badge to the far-right status-bar edge."""
|
|
placed = cls._status_title_badge(title, width)
|
|
if placed is None:
|
|
return cls._trim_status_bar_text(text, width)
|
|
badge, left_width = placed
|
|
left = cls._trim_status_bar_text(text.rstrip(), left_width)
|
|
padding = " " * max(0, left_width - cls._status_bar_display_width(left))
|
|
return f"{left}{padding} ─{badge}"
|
|
|
|
@classmethod
|
|
def _right_align_status_title_fragments(cls, frags, title: str, width: int):
|
|
"""Styled counterpart to :meth:`_right_align_status_title`."""
|
|
placed = cls._status_title_badge(title, width)
|
|
if placed is None:
|
|
return frags
|
|
badge, left_width = placed
|
|
trimmed = []
|
|
used = 0
|
|
for style, value in frags:
|
|
remaining = left_width - used
|
|
if remaining <= 0:
|
|
break
|
|
value_width = cls._status_bar_display_width(value)
|
|
if value_width <= remaining:
|
|
trimmed.append((style, value))
|
|
used += value_width
|
|
continue
|
|
clipped = cls._trim_status_bar_text(value, remaining)
|
|
if clipped:
|
|
trimmed.append((style, clipped))
|
|
used += cls._status_bar_display_width(clipped)
|
|
break
|
|
if used < left_width:
|
|
trimmed.append((_DIM, " " * (left_width - used)))
|
|
trimmed.extend([(_DIM, " ─"), ("class:status-bar-session-title", badge)])
|
|
return trimmed
|
|
|
|
@staticmethod
|
|
def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int:
|
|
"""Live prompt_toolkit width (can be narrower than shutil's, esp. Termux), falling
|
|
back to ``shutil``."""
|
|
try:
|
|
from prompt_toolkit.application import get_app
|
|
return get_app().output.get_size().columns
|
|
except Exception:
|
|
return shutil.get_terminal_size(default).columns
|
|
|
|
def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool:
|
|
"""Hide low-value chrome on narrow/mobile terminals to preserve rows."""
|
|
if width is None:
|
|
width = self._get_tui_terminal_width()
|
|
return width < 64
|
|
|
|
@staticmethod
|
|
def _scrollback_box_width(width: Optional[int] = None) -> int:
|
|
"""Full viewport width for printed scrollback box rules, floored at 32 cols so tiny
|
|
terminals never hit negative ``'─' * (w - 2)`` math. (The old 56-col clamp against
|
|
reflow-on-shrink is gone: the ``_output_screen_diff`` patch keeps chrome out of
|
|
scrollback, and reflow of already-printed borders is a cosmetic artifact.)
|
|
|
|
Previously this clamped to ``max(32, min(width, 56))`` as a defense against terminal-emulator reflow
|
|
on column-shrink (#25975, salvaging 24403). That clamp made response/reasoning borders look stubby
|
|
on any modern wide terminal. We now trust the prompt_toolkit ``_output_screen_diff`` monkey-patch
|
|
landed in #26137 (salvaging 25981) to keep chrome out of scrollback in the first place, and accept
|
|
that an aggressive column-shrink may visually reflow already printed Panel borders — that's a
|
|
cosmetic artifact of stamped scrollback history, not a live-render bug.
|
|
"""
|
|
if width is None:
|
|
try:
|
|
width = shutil.get_terminal_size((80, 24)).columns
|
|
except Exception:
|
|
width = 80
|
|
return max(32, int(width or 80))
|
|
|
|
def _agent_spacer_height(self, width: Optional[int] = None) -> int:
|
|
"""Spacer height above the status bar while the agent runs."""
|
|
if not getattr(self, "_agent_running", False):
|
|
return 0
|
|
return 0 if self._use_minimal_tui_chrome(width=width) else 1
|
|
|
|
def _spinner_widget_height(self, width: Optional[int] = None) -> int:
|
|
"""Visible height of the spinner/status line above the status bar."""
|
|
spinner_line = self._render_spinner_text()
|
|
if not spinner_line or self._use_minimal_tui_chrome(width=width):
|
|
return 0
|
|
width = width or self._get_tui_terminal_width()
|
|
if width and width > 10:
|
|
return max(1, -(-self._status_bar_display_width(spinner_line) // width))
|
|
return 1
|
|
|
|
def _render_spinner_text(self) -> str:
|
|
"""The live spinner/status text exactly as rendered in the TUI."""
|
|
txt = getattr(self, "_spinner_text", "")
|
|
if not txt:
|
|
return ""
|
|
flow = self._spinner_token_flow()
|
|
t0 = getattr(self, "_tool_start_time", 0) or 0
|
|
if t0 > 0:
|
|
elapsed = time.monotonic() - t0
|
|
# Fixed-width timers (01m05s / " 5.2s") avoid status-line wrap jitter on repaint.
|
|
if elapsed >= 60:
|
|
elapsed_str = t("cli.shared.duration_ms_compact",
|
|
minutes=f"{int(elapsed // 60):02d}", seconds=f"{int(elapsed % 60):02d}")
|
|
else:
|
|
elapsed_str = t("cli.shared.duration_s", seconds=f"{elapsed:5.1f}")
|
|
return f" {txt} ({elapsed_str} · {flow})" if flow else f" {txt} ({elapsed_str})"
|
|
return f" {txt} ({flow})" if flow else f" {txt}"
|
|
|
|
def _spinner_token_flow(self) -> str:
|
|
"""Cumulative output tokens for the running turn, for the spinner."""
|
|
if not getattr(self, "_spinner_token_flow_enabled", False):
|
|
return ""
|
|
if not getattr(self, "_agent_running", False):
|
|
return ""
|
|
agent = getattr(self, "agent", None)
|
|
if agent is None:
|
|
return ""
|
|
try:
|
|
from agent.turn_summary import format_token_flow
|
|
|
|
produced = (getattr(agent, "session_output_tokens", 0) or 0) - (
|
|
getattr(self, "_turn_token_baseline", 0) or 0)
|
|
return format_token_flow(produced)
|
|
except Exception:
|
|
return ""
|
|
|
|
def _turn_summary_is_active(self) -> bool:
|
|
"""Whether the per-turn summary line renders here: off for the config key, quiet /
|
|
tool-progress-off mode, and every non-interactive path (-q, -Q, gateway)."""
|
|
if not getattr(self, "_turn_summary_enabled", False):
|
|
return False
|
|
if getattr(self, "tool_progress_mode", "all") == "off":
|
|
return False
|
|
agent = getattr(self, "agent", None)
|
|
if agent is not None and getattr(agent, "quiet_mode", False):
|
|
return False
|
|
return bool(getattr(self, "_interactive_turn", False))
|
|
|
|
def _turn_summary_begin(self) -> None:
|
|
"""Start per-turn accounting for the turn that is about to run."""
|
|
try:
|
|
from agent.turn_summary import TurnSummaryCollector
|
|
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None:
|
|
collector = TurnSummaryCollector()
|
|
self._turn_summary_collector = collector
|
|
collector.begin()
|
|
self._turn_summary_start = time.monotonic()
|
|
agent = getattr(self, "agent", None)
|
|
self._turn_token_baseline = (
|
|
getattr(agent, "session_output_tokens", 0) or 0
|
|
) if agent is not None else 0
|
|
except Exception:
|
|
self._turn_summary_collector = None
|
|
|
|
def _turn_summary_record(self, function_name, result, is_error: bool) -> None:
|
|
"""Feed one completed tool call into the active tally."""
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None:
|
|
return
|
|
try:
|
|
collector.record_tool(function_name, result=result, is_error=bool(is_error))
|
|
except Exception:
|
|
pass
|
|
|
|
def _turn_summary_emit(self) -> None:
|
|
"""Print the post-turn accounting line, when enabled for this surface."""
|
|
from cli import _DIM as _D, _RST, _cprint, logger
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None or not self._turn_summary_is_active():
|
|
return
|
|
try:
|
|
started = getattr(self, "_turn_summary_start", 0.0) or 0.0
|
|
line = collector.render(max(0.0, time.monotonic() - started) if started else 0.0)
|
|
if line:
|
|
_cprint(f" {_D}{line}{_RST}")
|
|
except Exception:
|
|
logger.debug("Turn summary render failed", exc_info=True)
|
|
|
|
# ── pet pane ──────────────────────────────────────────────────────────────
|
|
|
|
def _pet_clear_runtime(self) -> None:
|
|
"""Drop renderer + queued Kitty state. Caller holds ``_pet_lock``."""
|
|
self._pet_enabled = False
|
|
self._pet_renderer = None
|
|
self._pet_frames_cache.clear()
|
|
self._pet_kitty_cache.clear()
|
|
self._pet_kitty_pending = ""
|
|
self._pet_kitty_image_id = 0
|
|
|
|
def _pet_resolve_config(self) -> None:
|
|
"""(Re)resolve the active pet from config so ``/pet`` / ``hermes pets`` changes apply
|
|
without a restart (mirrors the TUI's steady poll). Fail-open: any problem disables."""
|
|
try:
|
|
from agent.pet import constants, store
|
|
from hermes_cli.config import load_config
|
|
from utils import is_truthy_value
|
|
|
|
cfg = load_config()
|
|
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
|
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
|
enabled = is_truthy_value(pet_cfg.get("enabled"), default=False)
|
|
slug = str(pet_cfg.get("slug", "") or "")
|
|
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
|
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
|
configured_mode = str(pet_cfg.get("render_mode", "auto") or "auto").lower()
|
|
# Placeholders only on kitty/Ghostty: WezTerm speaks kitty APC but not U+10EEEE
|
|
# while detect_terminal_graphics() still says kitty, hence the narrower gate.
|
|
use_kitty = (
|
|
configured_mode in ("", "auto", "kitty") and pet_render.supports_kitty_placeholders()
|
|
)
|
|
renderer_mode = "kitty" if use_kitty else "unicode"
|
|
|
|
pet = None
|
|
if enabled and configured_mode != "off":
|
|
pet = store.resolve_active_pet(slug)
|
|
if pet is None or not pet.exists:
|
|
with self._pet_lock:
|
|
self._pet_clear_runtime()
|
|
return
|
|
|
|
with self._pet_lock:
|
|
# Rebuild only when the resolved pet, mode, or geometry changes.
|
|
if (
|
|
self._pet_renderer is None
|
|
or self._pet_slug != pet.slug
|
|
or self._pet_cols != cols
|
|
or self._pet_scale != scale
|
|
or self._pet_renderer.mode != renderer_mode):
|
|
self._pet_renderer = pet_render.PetRenderer(
|
|
str(pet.spritesheet), mode=renderer_mode, scale=scale, unicode_cols=cols)
|
|
self._pet_slug = pet.slug
|
|
self._pet_cols = cols
|
|
self._pet_scale = scale
|
|
self._pet_frames_cache.clear()
|
|
self._pet_kitty_cache.clear()
|
|
self._pet_kitty_pending = ""
|
|
self._pet_kitty_image_id = pet_render.kitty_image_id(pet.slug)
|
|
self._pet_frame_idx = 0
|
|
self._pet_enabled = True
|
|
except Exception:
|
|
with self._pet_lock:
|
|
self._pet_clear_runtime()
|
|
|
|
def _pet_flash(self, state: str, secs: float = 1.6) -> None:
|
|
"""Briefly force a transient reaction (wave/jump/failed) before resting."""
|
|
self._pet_event = state
|
|
self._pet_event_until = time.monotonic() + secs
|
|
|
|
def _on_reaction(self, kind: str) -> None:
|
|
"""Core-detected user affection (ily / <3 / good bot): the pet's share of the vibe
|
|
signal that plays hearts on the TUI/desktop."""
|
|
if kind == "vibe":
|
|
self._pet_flash("jump")
|
|
|
|
def _pet_react_turn_end(self) -> None:
|
|
"""End-of-turn beat: failed on error, jump on a finished plan, else wave."""
|
|
if not self._pet_enabled:
|
|
return
|
|
from agent.pet.state import todos_all_done
|
|
|
|
if self._pet_turn_error:
|
|
self._pet_flash("failed")
|
|
return
|
|
try:
|
|
store = getattr(self.agent, "_todo_store", None)
|
|
done = todos_all_done(store.read()) if store else False
|
|
except Exception:
|
|
done = False
|
|
self._pet_flash("jump" if done else "wave")
|
|
|
|
def _derive_pet_state(self) -> str:
|
|
"""A live transient beat wins; otherwise the shared ``agent.pet.state.derive_pet_state``
|
|
priority order so the CLI can't drift from the TUI/desktop."""
|
|
if self._pet_event and time.monotonic() < self._pet_event_until:
|
|
return self._pet_event
|
|
self._pet_event = ""
|
|
from agent.pet.state import derive_pet_state
|
|
|
|
# Any blocking modal (approval / clarify / sudo / secret / slash confirm) means the
|
|
# agent is paused on the user → `waiting`, which outranks the in-flight signals.
|
|
awaiting_input = bool(
|
|
self._approval_state
|
|
or self._clarify_state
|
|
or self._sudo_state
|
|
or self._secret_state
|
|
or getattr(self, "_slash_confirm_state", None))
|
|
return derive_pet_state(
|
|
awaiting_input=awaiting_input,
|
|
busy=getattr(self, "_agent_running", False),
|
|
reasoning=self._pet_reasoning,
|
|
).value
|
|
|
|
def _pet_frames_for(self, state: str) -> list:
|
|
"""Return (and cache) the half-block grids for one state."""
|
|
cached = self._pet_frames_cache.get(state)
|
|
if cached is not None:
|
|
return cached
|
|
renderer = self._pet_renderer
|
|
if renderer is None:
|
|
return []
|
|
try:
|
|
count = renderer.frame_count(state) or 1
|
|
grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)]
|
|
except Exception:
|
|
grids = []
|
|
self._pet_frames_cache[state] = grids
|
|
return grids
|
|
|
|
def _pet_kitty_payload_for(self, state: str) -> dict | None:
|
|
"""Return and cache a Kitty virtual-placeholder payload for *state*."""
|
|
with self._pet_lock:
|
|
cached = self._pet_kitty_cache.get(state)
|
|
if cached is not None:
|
|
return cached
|
|
renderer = self._pet_renderer
|
|
image_id = self._pet_kitty_image_id
|
|
if renderer is None or renderer.mode != "kitty":
|
|
return None
|
|
try:
|
|
# PNG encoding outside _pet_lock: first visit of a state must not stall the prompt.
|
|
payload = renderer.kitty_payload(state, image_id=image_id)
|
|
except Exception:
|
|
payload = None
|
|
if payload is not None:
|
|
payload = {**payload, "image_id": image_id}
|
|
with self._pet_lock:
|
|
if self._pet_renderer is renderer and self._pet_kitty_image_id == image_id:
|
|
self._pet_kitty_cache[state] = payload
|
|
return payload
|
|
|
|
def _pet_queue_kitty_frame(self, state: str | None = None) -> None:
|
|
"""Queue one virtual Kitty frame for the next prompt_toolkit render. No-op when the
|
|
pet pane was never initialized (``__new__`` fixtures, redraw on a pet-less CLI)."""
|
|
if not getattr(self, "_pet_enabled", False):
|
|
return
|
|
if state is None:
|
|
state = self._derive_pet_state()
|
|
payload = self._pet_kitty_payload_for(state)
|
|
if not payload or not payload.get("frames"):
|
|
return
|
|
with self._pet_lock:
|
|
if self._pet_renderer is not None and self._pet_renderer.mode == "kitty":
|
|
frames = payload["frames"]
|
|
self._pet_kitty_pending = frames[self._pet_frame_idx % len(frames)]
|
|
|
|
def _pet_flush_kitty_frame(self, app) -> None:
|
|
"""Write a queued APC after prompt_toolkit has finished its screen diff."""
|
|
with self._pet_lock:
|
|
frame = self._pet_kitty_pending
|
|
self._pet_kitty_pending = ""
|
|
if not frame:
|
|
return
|
|
try:
|
|
# U=1/q=2 leaves the cursor and input stream untouched.
|
|
app.output.write_raw(frame)
|
|
app.output.flush()
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
def _pet_view(self) -> "tuple[str, bool] | None":
|
|
"""(state, is_kitty) for the current frame, or None when no pet shows."""
|
|
with self._pet_lock:
|
|
if not self._pet_enabled or self._pet_renderer is None:
|
|
return None
|
|
return self._derive_pet_state(), self._pet_renderer.mode == "kitty"
|
|
|
|
def _pet_fragments(self):
|
|
"""prompt_toolkit FormattedText for the current pet frame, or []."""
|
|
view = self._pet_view()
|
|
if view is None:
|
|
return []
|
|
state, kitty = view
|
|
frags = []
|
|
if kitty:
|
|
payload = self._pet_kitty_payload_for(state)
|
|
if not payload:
|
|
return []
|
|
color = pet_render.kitty_color_hex(payload["image_id"])
|
|
for y, row in enumerate(payload["placeholder"]):
|
|
if y:
|
|
frags.append(("", "\n"))
|
|
frags.append((f"fg:{color}", row))
|
|
return frags
|
|
with self._pet_lock:
|
|
grids = self._pet_frames_for(state)
|
|
if not grids:
|
|
return []
|
|
grid = grids[self._pet_frame_idx % len(grids)]
|
|
|
|
def _hex(r, g, b):
|
|
return f"#{r:02x}{g:02x}{b:02x}"
|
|
|
|
for y, row in enumerate(grid):
|
|
if y:
|
|
frags.append(("", "\n"))
|
|
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
|
top_op, bot_op = ta >= 32, ba >= 32
|
|
if not top_op and not bot_op:
|
|
frags.append(("", " "))
|
|
elif top_op and bot_op:
|
|
frags.append((f"fg:{_hex(tr, tg, tb)} bg:{_hex(br, bg, bb)}", "▀"))
|
|
elif top_op:
|
|
# Upper half only — leave the lower half the terminal's bg (cleaner on light
|
|
# themes).
|
|
frags.append((f"fg:{_hex(tr, tg, tb)}", "▀"))
|
|
else:
|
|
frags.append((f"fg:{_hex(br, bg, bb)}", "▄"))
|
|
return frags
|
|
|
|
def _pet_widget_height(self) -> int:
|
|
"""Visible rows for the pet window — 0 collapses it when no pet shows."""
|
|
view = self._pet_view()
|
|
if view is None:
|
|
return 0
|
|
state, kitty = view
|
|
if kitty:
|
|
payload = self._pet_kitty_payload_for(state)
|
|
return int(payload.get("rows", 0)) if payload else 0
|
|
with self._pet_lock:
|
|
grids = self._pet_frames_for(state)
|
|
return len(grids[0]) if grids and grids[0] else 0
|
|
|
|
def _pet_anim_loop(self) -> None:
|
|
"""Advance the frame + invalidate on a timer while a pet is enabled."""
|
|
while self._pet_anim_running:
|
|
time.sleep(self._PET_FRAME_INTERVAL)
|
|
if getattr(self, "_terminal_io_broken", False):
|
|
self._pet_anim_running = False
|
|
break
|
|
now = time.monotonic()
|
|
if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL:
|
|
self._pet_cfg_checked = now
|
|
self._pet_resolve_config()
|
|
if not self._pet_enabled:
|
|
continue
|
|
with self._pet_lock:
|
|
self._pet_frame_idx += 1
|
|
kitty = self._pet_renderer is not None and self._pet_renderer.mode == "kitty"
|
|
if kitty:
|
|
self._pet_queue_kitty_frame()
|
|
app = getattr(self, "_app", None)
|
|
if app is not None:
|
|
try:
|
|
app.invalidate()
|
|
except OSError as exc:
|
|
if getattr(exc, "errno", None) == errno.EIO:
|
|
self._mark_terminal_io_broken("pet_anim")
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
def _pet_start_anim(self) -> None:
|
|
if self._pet_anim_running:
|
|
return
|
|
self._pet_resolve_config()
|
|
view = self._pet_view()
|
|
if view is not None and view[1]:
|
|
self._pet_queue_kitty_frame()
|
|
self._pet_anim_running = True
|
|
self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True)
|
|
self._pet_anim_thread.start()
|
|
|
|
def _pet_stop_anim(self) -> None:
|
|
self._pet_anim_running = False
|
|
thread = self._pet_anim_thread
|
|
if thread is not None:
|
|
thread.join(timeout=0.3)
|
|
self._pet_anim_thread = None
|
|
|
|
# ── voice ─────────────────────────────────────────────────────────────────
|
|
|
|
def _voice_record_key_label(self) -> str:
|
|
"""The push-to-talk key label every voice-facing hint advertises. Cached at startup
|
|
(``set_voice_record_key_cache``) because the prompt_toolkit binding is registered once —
|
|
re-reading config per render could advertise a chord that isn't bound — and this sits on
|
|
the hot render path.
|
|
|
|
Two reasons (Copilot round-13 on 19835): See #19835.
|
|
"""
|
|
return getattr(self, "_voice_record_key_display_cache", None) or "Ctrl+B"
|
|
|
|
def set_voice_record_key_cache(self, raw_key: object) -> None:
|
|
"""Populate the voice label cache from a raw ``voice.record_key``; called after the
|
|
prompt_toolkit binding is registered so the label matches the live binding."""
|
|
try:
|
|
from hermes_cli.voice import format_voice_record_key_for_status
|
|
self._voice_record_key_display_cache = format_voice_record_key_for_status(raw_key)
|
|
except Exception:
|
|
self._voice_record_key_display_cache = "Ctrl+B"
|
|
|
|
def _get_voice_status_fragments(self, width: Optional[int] = None):
|
|
"""Voice status bar fragments for the interactive TUI."""
|
|
width = width or self._get_tui_terminal_width()
|
|
compact = self._use_minimal_tui_chrome(width=width)
|
|
label = self._voice_record_key_label()
|
|
if self._voice_recording:
|
|
if compact:
|
|
return [("class:voice-status-recording", f" {t('cli.status_bar.voice_rec')} ")]
|
|
return [("class:voice-status-recording", f" {t('cli.status_bar.voice_rec_stop_hint', shortcut=label)} ")]
|
|
if self._voice_processing:
|
|
return [("class:voice-status",
|
|
f" {t('cli.status_bar.voice_stt')} " if compact else f" {t('cli.status_bar.voice_transcribing')} ")]
|
|
if compact:
|
|
return [("class:voice-status", f" 🎤 {label} ")]
|
|
tts = t("cli.status_bar.voice_tts_on") if self._voice_tts else ""
|
|
cont = t("cli.status_bar.voice_continuous") if self._voice_continuous else ""
|
|
return [("class:voice-status", f" {t('cli.status_bar.voice_mode_hint', tts=tts, cont=cont, shortcut=label)} ")]
|
|
|
|
# ── status bar rendering ──────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _status_bar_goal_segment(snapshot: Dict[str, Any]) -> str:
|
|
"""``⊙ goal 3/20`` while a goal is active, else ``""`` (paused/done goals already
|
|
print their own glyph lines in the thread)."""
|
|
if not snapshot.get("goal_active"):
|
|
return ""
|
|
used = snapshot.get("goal_turns_used") or 0
|
|
max_turns = snapshot.get("goal_max_turns") or 0
|
|
return t("cli.status_bar.goal_turns", used=used, max=max_turns) if max_turns else t("cli.status_bar.goal")
|
|
|
|
def _get_status_bar_field_set(self) -> Optional[frozenset]:
|
|
"""Visible status-bar fields from ``display.status_bar.fields`` (module-level
|
|
``CLI_CONFIG``; no per-render YAML parse). ``None`` = not customized, show everything.
|
|
|
|
Fields: model, context_detail, context_pct, cache_hit, latency, tps, compressions,
|
|
bg_tasks, bg_processes, bg_subagents, goal, git_branch (opt-in only), duration,
|
|
prompt_elapsed, idle_since, focus, yolo, stash, battery, title, total_tokens
|
|
(opt-in only). Order is fixed; the config controls visibility only.
|
|
"""
|
|
from cli import CLI_CONFIG
|
|
if hasattr(self, "_status_bar_field_set_cache"):
|
|
return self._status_bar_field_set_cache
|
|
result = None
|
|
try:
|
|
display = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else None
|
|
status_bar = (display or {}).get("status_bar") if isinstance(display, dict) else None
|
|
fields = status_bar.get("fields") if isinstance(status_bar, dict) else None
|
|
if isinstance(fields, list) and fields:
|
|
result = frozenset(str(f) for f in fields)
|
|
except Exception:
|
|
result = None
|
|
self._status_bar_field_set_cache = result
|
|
return result
|
|
|
|
def _status_bar_segments(
|
|
self, snapshot, width: int, field_set, yolo_active: bool, *, styled: bool) -> list:
|
|
"""Ordered status-bar segments for one width tier (<52 / <76 / wide), each a list of
|
|
``(style, text)`` fragments. Shared by the plain-text and prompt_toolkit renderers so
|
|
the two can never drift; ``styled`` selects the graphical context bar."""
|
|
from cli import format_token_count_compact
|
|
model_short = snapshot["model_short"]
|
|
duration_label = snapshot["duration"]
|
|
goal_segment = self._status_bar_goal_segment(snapshot)
|
|
focus_label = snapshot.get("focus_label") or ""
|
|
|
|
def _ok(name: str) -> bool:
|
|
return field_set is None or name in field_set
|
|
|
|
segs: list = []
|
|
|
|
def add(name, style, text):
|
|
if _ok(name):
|
|
segs.append([(style, text)])
|
|
|
|
def add_count(name, key, glyph, style=_STRONG):
|
|
count = snapshot.get(key, 0)
|
|
if count:
|
|
add(name, style(count) if callable(style) else style, f"{glyph} {count}")
|
|
|
|
if _ok("model"):
|
|
if styled:
|
|
segs.append([(_SB, " ☤ "), (_STRONG, model_short)])
|
|
else:
|
|
segs.append([("", f"☤ {model_short}")])
|
|
narrow, wide = width < 52, width >= 76
|
|
if narrow:
|
|
# Narrow bars put duration ahead of the goal segment; the other tiers reverse it.
|
|
add("duration", _DIM, duration_label)
|
|
else:
|
|
percent = snapshot["context_percent"]
|
|
mark = "~" if snapshot.get("context_estimated") else ""
|
|
percent_label = f"{mark}{percent}%" if percent is not None else "--"
|
|
if wide and _ok("context_detail"):
|
|
if snapshot["context_length"]:
|
|
ctx_total = _format_context_length(snapshot["context_length"])
|
|
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
|
pin = t("cli.status_bar.ctx_pinned") if snapshot.get("context_pinned") else ""
|
|
context_label = f"{mark}{ctx_used}/{ctx_total}{pin}"
|
|
else:
|
|
context_label = t("cli.status_bar.ctx_unknown")
|
|
segs.append([(_DIM, context_label)])
|
|
if _ok("context_pct"):
|
|
bar_style = self._status_bar_context_style(percent)
|
|
if wide and styled:
|
|
segs.append([
|
|
(bar_style, self._build_context_bar(percent)), (_DIM, " "), (bar_style, percent_label),
|
|
])
|
|
else:
|
|
segs.append([(bar_style, percent_label)])
|
|
cache = self._cache_hit_rate(snapshot, precision=1 if wide else 0)
|
|
if cache:
|
|
add("cache_hit", self._cache_hit_rate_style(cache[0]), cache[1])
|
|
if wide:
|
|
for name, key, glyph in (
|
|
("latency", "avg_latency_label", "◷"), ("tps", "avg_velocity_label", "↑")):
|
|
label = snapshot.get(key) or ""
|
|
if label:
|
|
add(name, _DIM, f"{glyph} {label}")
|
|
add_count("compressions", "compressions", "🗜️", self._compression_count_style)
|
|
add_count("bg_tasks", "active_background_tasks", "▶")
|
|
add_count("bg_processes", "active_background_processes", "⚙")
|
|
add_count("bg_subagents", "active_background_subagents", "⛓")
|
|
if goal_segment:
|
|
add("goal", _STRONG, goal_segment)
|
|
git_branch = snapshot.get("git_branch") or ""
|
|
if git_branch:
|
|
add("git_branch", _DIM, f"⎇ {git_branch}")
|
|
if not narrow:
|
|
add("duration", _DIM, duration_label)
|
|
if wide:
|
|
for name in ("prompt_elapsed", "idle_since"):
|
|
label = snapshot.get(name)
|
|
if label:
|
|
add(name, _DIM, label)
|
|
if focus_label:
|
|
add("focus", _STRONG, focus_label)
|
|
if yolo_active:
|
|
add("yolo", "class:status-bar-yolo", t("cli.status_bar.yolo_badge"))
|
|
if wide:
|
|
# Session token total (Σ) — opt-in only via an explicit fields list.
|
|
total_tokens = snapshot.get("session_total_tokens", 0)
|
|
if total_tokens and field_set is not None and "total_tokens" in field_set:
|
|
segs.append([(_DIM, f"Σ{format_token_count_compact(total_tokens)}")])
|
|
return segs
|
|
|
|
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
|
|
"""Compact one-line session status string for the TUI footer."""
|
|
try:
|
|
snapshot = self._get_status_bar_snapshot()
|
|
if width is None:
|
|
width = self._get_tui_terminal_width()
|
|
model_short = snapshot["model_short"]
|
|
battery_label = snapshot.get("battery_label") or ""
|
|
field_set = self._get_status_bar_field_set()
|
|
show_title = field_set is None or "title" in field_set
|
|
session_title = (snapshot.get("session_title") or "") if show_title else ""
|
|
segs = self._status_bar_segments(
|
|
snapshot, width, field_set, self._is_session_yolo_active(), styled=False)
|
|
parts = ["".join(t for _, t in seg) for seg in segs] or [f"☤ {model_short}"]
|
|
# Narrow bars always join the battery with │; wider tiers use the tier separator.
|
|
if battery_label:
|
|
parts.insert(0, battery_label)
|
|
if width < 52:
|
|
text = f"{parts[0]} │ " + " · ".join(parts[1:]) if battery_label else " · ".join(parts)
|
|
else:
|
|
text = (" · " if width < 76 else " │ ").join(parts)
|
|
return self._right_align_status_title(text, session_title, width)
|
|
except Exception:
|
|
return f"☤ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
|
|
|
def _get_status_bar_fragments(self):
|
|
if (
|
|
not self._status_bar_visible
|
|
or getattr(self, "_model_picker_state", None)
|
|
or getattr(self, "_command_palette_state", None)):
|
|
return []
|
|
try:
|
|
snapshot = self._get_status_bar_snapshot()
|
|
# prompt_toolkit's own width: shutil's can be stale (esp. over SSH) and an overflow
|
|
# produces duplicated status-bar rows over long sessions.
|
|
width = self._get_tui_terminal_width()
|
|
field_set = self._get_status_bar_field_set()
|
|
|
|
def _ok(name: str) -> bool:
|
|
return field_set is None or name in field_set
|
|
|
|
session_title = (snapshot.get("session_title") or "") if _ok("title") else ""
|
|
segs = self._status_bar_segments(
|
|
snapshot, width, field_set, self._is_session_yolo_active(), styled=True)
|
|
sep = " · " if width < 76 else " │ "
|
|
frags: list = []
|
|
for seg in segs or [[(_SB, " ☤ "), (_STRONG, snapshot["model_short"])]]:
|
|
if frags:
|
|
frags.append((_DIM, sep))
|
|
frags.extend(seg)
|
|
# Stash indicator (📌 N) after every width tier so a parked draft is never
|
|
# invisible; before the battery prepend, and the first thing the trim drops.
|
|
try:
|
|
stash_indicator = self._prompt_stash.indicator()
|
|
except Exception:
|
|
stash_indicator = ""
|
|
if stash_indicator and _ok("stash"):
|
|
frags.extend([(_DIM, " · "), (_STRONG, stash_indicator)])
|
|
frags.append((_SB, " ")) # one-cell right margin
|
|
# Battery is the first element when enabled: prepend ahead of the ☤ marker.
|
|
battery_label = snapshot.get("battery_label") or ""
|
|
if battery_label and _ok("battery"):
|
|
battery_style = self._battery_status_style(snapshot.get("battery_category", "dim"))
|
|
frags[0:0] = [(_SB, " "), (battery_style, battery_label), (_DIM, " │")]
|
|
|
|
frags = self._right_align_status_title_fragments(frags, session_title, width)
|
|
vim_label = self._vim_mode_label()
|
|
if vim_label:
|
|
frags.extend([(_DIM, " │ "), (_STRONG, vim_label), (_SB, " ")])
|
|
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
|
if total_width > width:
|
|
plain_text = "".join(text for _, text in frags)
|
|
return [(_SB, self._trim_status_bar_text(plain_text, width))]
|
|
return frags
|
|
except Exception:
|
|
return [(_SB, f" {self._build_status_bar_text()} ")]
|
|
|
|
# ── prompt stash panel ────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _fmt_stash_age(stashed_at: float) -> str:
|
|
secs = int(time.monotonic() - stashed_at)
|
|
if secs < 10:
|
|
return t("cli.shared.just_now")
|
|
if secs < 90:
|
|
return t("cli.shared.ago_seconds", seconds=secs)
|
|
mins = secs // 60
|
|
return t("cli.shared.ago_minutes", minutes=mins) if mins < 60 else t("cli.shared.ago_hours", hours=mins // 60)
|
|
|
|
def _render_stash_panel(self, stash_list: list, cursor: int, width: int) -> list:
|
|
"""prompt_toolkit fragments for the stash panel box. Every horizontal measurement uses
|
|
``_status_bar_display_width`` (display cells), not ``len()`` — 📌 is one codepoint but
|
|
two cells, and CJK previews would otherwise bleed past the right border."""
|
|
cw = self._status_bar_display_width
|
|
W = max(12, min(width - 4, 80))
|
|
|
|
n = len(stash_list)
|
|
hdr_key = "cli.status_bar.stash_header_one" if n == 1 else "cli.status_bar.stash_header_other"
|
|
hdr_prefix_str = f"╭─ {t(hdr_key, count=n)} "
|
|
HDR_SUFFIX = " Ctrl+S ─╮"
|
|
FTR_PREFIX = "╰"
|
|
FTR_SUFFIX = f" {t('cli.status_bar.stash_footer')} ─╯"
|
|
|
|
# On narrow terminals the full hint text is wider than the box: drop to compact
|
|
# affordances rather than letting the frame bleed past the right edge.
|
|
if cw(hdr_prefix_str) + cw(HDR_SUFFIX) > W:
|
|
hdr_prefix_str = f"╭─ 📌 {n} "
|
|
HDR_SUFFIX = "─╮"
|
|
if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W:
|
|
FTR_SUFFIX = f" {t('cli.status_bar.stash_footer_compact')} ─╯"
|
|
if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W:
|
|
FTR_SUFFIX = "─╯"
|
|
|
|
hdr_dashes = max(0, W - cw(hdr_prefix_str) - cw(HDR_SUFFIX))
|
|
ftr_dashes = max(0, W - cw(FTR_PREFIX) - cw(FTR_SUFFIX))
|
|
INNER = W - 2 # minus the two '│' border cells
|
|
frags: list = []
|
|
|
|
def line(text: str, style: str = "") -> None:
|
|
# Final guard: never emit a line wider than the box.
|
|
frags.append((style, self._trim_status_bar_text(text, W) + "\n"))
|
|
|
|
line(f"{hdr_prefix_str}{'─' * hdr_dashes}{HDR_SUFFIX}", "class:subagent-border")
|
|
for i, item in enumerate(stash_list):
|
|
age = self._fmt_stash_age(item["stashed_at"])
|
|
marker = "►" if i == cursor else " "
|
|
prefix = f" {marker} [{i + 1}] {age:<10} "
|
|
if cw(prefix) > INNER - 2:
|
|
prefix = f" {marker} [{i + 1}] "
|
|
avail = max(0, INNER - cw(prefix) - 1)
|
|
preview = self._trim_status_bar_text(item.get("preview") or "", avail)
|
|
preview = preview + " " * max(0, avail - cw(preview))
|
|
if i == cursor:
|
|
row = self._trim_status_bar_text(f"│{prefix}{preview} │", W)
|
|
frags.append(("class:subagent-selected", row + "\n"))
|
|
else:
|
|
frags.append(("class:subagent-border", "│"))
|
|
frags.append(("class:subagent-sub", f"{prefix}{preview} "))
|
|
frags.append(("class:subagent-border", "│\n"))
|
|
line(f"{FTR_PREFIX}{'─' * ftr_dashes}{FTR_SUFFIX}", "class:subagent-border")
|
|
return frags
|