Files
hermes-agent/hermes_cli/cli_terminal_mixin.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* 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>
2026-09-28 14:16:18 -07:00

669 lines
34 KiB
Python

"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive
CLI. Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle)."""
from __future__ import annotations
import base64
import errno
import os
import shutil
import sys
import threading
import time
from agent.i18n import t
from hermes_cli.cli_render import (
_chrome_floor,
_hold_paints,
_output_history_rows,
_release_paints,
_set_chrome_floor,
_set_paint_gate,
_take_suspect_rows,
)
from hermes_constants import get_hermes_home
# A replay ``fit`` with no room: nothing is replayed.
_NO_REPLAY = (0, 0, False, None)
# How long a resize drag holds output before its next width change runs a recovery anyway,
# that long after the change (seconds): output freezes about their sum plus one signal interval.
_RESIZE_HOLD_MAX = 0.7
_RESIZE_SETTLE = 0.03
# How long before a width change was first seen a chrome paint may still have reached the
# terminal after it narrowed (seconds; a paint the width moved under is always suspect): the
# signal lands a few ms after the multiplexer re-wraps, and it reads our output promptly — in
# tmux, paints that ended up to 4 ms before the signal were re-wrapped, never clipped (#95375).
_RESIZE_PAINT_MARGIN = 0.01
def _is_eio(exc: BaseException) -> bool:
return getattr(exc, "errno", None) == errno.EIO
def _run_on_app_loop(app, fn) -> None:
"""Run *fn* on the app's asyncio loop when one exists, else inline (fail-open)."""
try:
loop = getattr(app, "loop", None)
except Exception:
loop = None
if loop is not None:
try:
loop.call_soon_threadsafe(fn)
return
except Exception:
pass
fn()
def _write_terminal_sequence(app, seq: str) -> None:
"""Write a raw escape *seq* via the app output (write_raw > write) or stdout."""
output = getattr(app, "output", None) if app else None
if output and hasattr(output, "write_raw"):
output.write_raw(seq)
output.flush()
elif output and hasattr(output, "write"):
output.write(seq)
output.flush()
else:
sys.stdout.write(seq)
sys.stdout.flush()
class CLITerminalMixin:
"""Terminal repaint/resize recovery, input-mode healing, and clipboard helpers for the interactive CLI"""
def _mark_terminal_io_broken(self, reason: str = "") -> None:
"""Stop UI paints after the PTY/stdout becomes unusable (#81521)."""
from cli import logger
if getattr(self, "_terminal_io_broken", False):
return
self._terminal_io_broken = True
try:
self._pet_stop_anim()
except Exception:
pass
logger.warning(
"Terminal I/O broken%s — freezing UI paints to avoid redraw storm (#81521)",
f" ({reason})" if reason else "")
def _app_invalidate(self, app, where: str, *, swallow: bool) -> None:
"""``app.invalidate()``: EIO freezes paints, other OSErrors re-raise, and any
other exception re-raises unless *swallow*."""
try:
app.invalidate()
except OSError as exc:
if _is_eio(exc):
self._mark_terminal_io_broken(where)
return
raise
except Exception:
if not swallow:
raise
def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint for high-frequency background updates (spinner frames,
streaming flushes): the throttle prevents blinking on slow/SSH links and the
resize-recovery guard keeps footer chrome out of scrollback mid-SIGWINCH.
NOT for user-blocking modals (approval / clarify / sudo) — use ``_paint_now``: a
throttled or resize-gated entry paint is silently dropped, so the prompt never
renders and times out unseen (#41098).
"""
if getattr(self, "_terminal_io_broken", False) or getattr(self, "_resize_recovery_pending", False):
return
now = time.monotonic()
# None sentinel, not 0.0 — monotonic's epoch is arbitrary (boot on Linux), so on
# a fresh VM ``now`` can be < min_interval and a 0.0 sentinel would swallow the
# first repaint (same class as _schedule_focus_regain_redraw below).
last = getattr(self, "_last_invalidate", None)
if hasattr(self, "_app") and self._app and (last is None or now - last >= min_interval):
self._last_invalidate = now
self._app_invalidate(self._app, "invalidate", swallow=False)
def _paint_now(self) -> None:
"""Unthrottled repaint for user-blocking modal prompts.
Bypasses the ``_invalidate`` throttle — a modal the user is waiting on must never be
dropped (#41098) — mirroring the direct ``event.app.invalidate()`` the modal key-binding
handlers use. While a width change awaits its recovery the app's redraw is gated
(``_install_resize_recovery``), so the paint waits for that recovery, which repaints the
whole app about every ``_RESIZE_HOLD_MAX`` seconds even while a drag keeps signalling.
"""
if getattr(self, "_terminal_io_broken", False):
return
monitor = getattr(self, "_subagent_monitor", None)
if monitor is not None:
monitor.invalidate()
app = getattr(self, "_app", None)
if app is not None:
self._app_invalidate(app, "paint_now", swallow=True)
def _force_full_redraw(self) -> None:
"""Force a clean full-screen repaint of the prompt_toolkit UI (Ctrl+L, ``/redraw``).
Recovers from terminal buffer drift caused by external redraws we can't detect
(cmux/tmux tab switches, ``clear`` from a subshell, SSH window restores): they
repaint without SIGWINCH, so prompt_toolkit's tracked ``_cursor_pos`` is stale
and the next incremental redraw stacks on old content (ghost status bars).
"""
from cli import _replay_output_history
if getattr(self, "_terminal_io_broken", False):
return
app = getattr(self, "_app", None)
if not app:
return
fit = self._clear_prompt_toolkit_screen(app, rebuild_scrollback=self._redraw_rebuilds_scrollback())
if getattr(self, "_terminal_io_broken", False):
return
# A viewport refill paints at once, before prompt_toolkit's next erase; after CSI 3J
# the whole history goes through the usual print (the screen was cleared anyway).
_replay_output_history(fit, None if fit is None else app.renderer.output)
self._pet_queue_kitty_frame()
self._app_invalidate(app, "force_full_redraw", swallow=True)
def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None:
"""Repaint after a terminal focus-in report (``CSI I``), at most once per
``min_interval`` (rapid Alt+Tab / pane-hop bursts). Emulators with focus tracking
(DECSET 1004) may coalesce hidden-tab output, so on regain the incremental diff
stacks on stale content (#60920, #25337); terminals without it never emit ``CSI I``.
"""
now = time.monotonic()
# Sentinel is None, not 0.0: time.monotonic() counts from an arbitrary epoch
# (boot on Linux). On a fresh VM (CI runners, containers) ``now`` can be smaller
# than ``min_interval``, and ``now - 0.0 < min_interval`` would suppress the
# FIRST redraw ever requested (CI run 32494557030: uptime < 60s made the
# min_interval=60 rate-limit test fail both attempts).
last = getattr(self, "_last_focus_regain_redraw", None)
if last is not None and now - last < min_interval:
return
self._last_focus_regain_redraw = now
self._force_full_redraw()
@staticmethod
def _redraw_rebuilds_scrollback() -> bool:
"""Whether redraw/resize recovery should also clear scrollback (CSI 3J).
Some terminal/tmux stacks move prompt_toolkit's bottom chrome into scrollback on
maximize/restore; CSI 2J cannot remove those rows, so affected users opt in to 3J
followed by the bounded output-history replay.
"""
from cli import CLI_CONFIG
display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {}
raw = (display_config.get("cli_rebuild_scrollback_on_redraw", False)
if isinstance(display_config, dict) else False)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on", "always"}
return bool(raw)
def _recover_terminal_after_interrupt(self) -> None:
"""Recover the terminal after an interrupted agent turn (#33271): an in-flight
``CSI 6n`` reply arriving after the input parser tore down leaks as literal text
and can stall the VT100 parser mid-escape. Drain stdin, then full redraw; each step
self-guards. A dead PTY (EIO) skips the redraw (#81521 redraw storm). Never clear
output history here — the interruption marker is printed under
``_suspend_output_history``, so replay already excludes it (#60920).
"""
if getattr(self, "_terminal_io_broken", False):
return
try:
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
except Exception:
pass
self._force_full_redraw()
def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False, keep_above: bool = False):
"""Clear the terminal and reset prompt_toolkit renderer state.
Returns the ``fit`` for the replay that follows (``_transcript_room`` plus where to
paint it). ``None`` means replay the whole history (scrollback wiped by CSI 3J). Without
3J the older transcript stays in scrollback, so the replay may only repaint what the
viewport held (#95375); the viewport is erased row by row because CSI 2J makes
scroll-on-clear terminals (tmux, VTE) copy the whole screen into scrollback first,
stacking a duplicate. With ``keep_above`` (a resize), on a terminal that keeps rows in
place, and the whole history on screen, only its rows and the chrome are erased — what
sits above them (the startup banner) was never recorded and a replay could not restore
it. Counted as painted, the replay leaves the chrome's top at a known row: the chrome is
drawn from there to the bottom (``_set_chrome_floor``), where the next count assumes it.
A clear that fails replays nothing: the history is already on screen or in scrollback.
"""
from cli import _terminal_reflows
if getattr(self, "_terminal_io_broken", False):
return _NO_REPLAY
try:
renderer = app.renderer
out = renderer.output
size = out.get_size()
fit = None
out.reset_attributes()
if rebuild_scrollback:
out.erase_screen()
out.write_raw("\x1b[3J")
out.cursor_goto(0, 0)
_set_chrome_floor(None)
else:
room, columns, painted = self._transcript_room(app)
room += _take_suspect_rows()
# Only where rows stay in place: the erase below counts up from the cursor.
keep_above = keep_above and _terminal_reflows() is False
history_rows = _output_history_rows(room, columns, painted) if keep_above else None
if keep_above and history_rows is not None:
# Rows stay where prompt_toolkit's cursor has them: its oldest row is
# ``history_rows`` above the chrome's top — row ``room`` once that is known.
out.write_raw("\r")
out.cursor_up(renderer._cursor_pos.y + history_rows)
out.erase_down()
known = painted and _chrome_floor() is not None
fit = (room, columns, painted, room - history_rows if known else None)
else:
for row in range(1, size.rows + 1): # CUP rows count from 1
out.cursor_goto(row, 0)
out.erase_end_of_line()
out.cursor_goto(0, 0)
fit = (room, columns, painted, 0 if painted else None)
out.flush()
# Drop cached screen + cursor state so the next _redraw() starts from a
# known (0, 0) origin and re-renders every cell instead of diffing stale.
renderer.reset(leave_alternate_screen=False)
return fit
except OSError as exc:
if _is_eio(exc):
self._mark_terminal_io_broken("clear_screen")
except Exception:
pass
return _NO_REPLAY
@staticmethod
def _transcript_room(app):
"""``(rows, columns, painted)``: the viewport rows the transcript fills above the
prompt chrome, and how to count them — at the current ``columns``, or with ``painted``
at the width each line was painted at, as a terminal that does not reflow keeps them.
Unless the terminal is known to reflow, count as painted: that replays at least what
the viewport held, never less.
The chrome is the renderer's last paint: prompt_toolkit may draw the app taller than
its preferred height (it fills the rows below the cursor it measured, or down to the
bottom row once a refill left its top at a known row).
"""
from cli import _terminal_reflows
renderer = app.renderer
size = renderer.output.get_size()
screen = renderer._last_screen
if screen is None:
drawn = app.layout.container.preferred_height(size.columns, size.rows).preferred
else:
drawn = screen.height
rows = max(0, size.rows - max(renderer._min_available_height, drawn, _chrome_floor() or 0))
return rows, size.columns, _terminal_reflows() is not True
@staticmethod
def _painted_row_widths(renderer) -> list[int]:
"""Cells each row of the renderer's last paint reaches: prompt_toolkit writes a row up to
its last cell that shows something (the blanks after it are erase-to-end-of-line)."""
screen = renderer._last_screen
if screen is None:
return []
has_style = renderer._style_string_has_style
return [1 + max((x for x, ch in screen.data_buffer[y].items() if ch.char != " " or has_style[ch.style]),
default=0) for y in range(screen.height)]
def _note_chrome_paint(self, app, full: bool) -> None:
"""Remember when each row of the chrome prompt_toolkit just drew was written, and how
wide — ``full``: the whole chrome was written anew (for ``_aim_erase_at_reflowed_chrome``).
A row only changes height when its width does, so only those changes are kept."""
renderer = app.renderer
size = renderer._last_size
try: # the width moved while it rendered: the terminal may have got the rows clipped
suspect = size is None or app.output.get_size().columns != size.columns
except Exception:
suspect = True
now = time.monotonic()
previous = [] if full else getattr(self, "_chrome_paints", [])
paints = []
for y, width in enumerate(self._painted_row_widths(renderer)):
history = previous[y] if y < len(previous) else []
if not history or history[-1][1] != width:
history = [*history[-3:], (now, width, suspect)]
paints.append(history)
self._chrome_paints = paints
self._chrome_paints_screen = renderer._last_screen
def _aim_erase_at_reflowed_chrome(self, app, columns: int) -> None:
"""Point prompt_toolkit's erase at the top of its last paint as a reflowing terminal
re-wrapped it to ``columns`` (#95375).
``renderer.erase()`` moves up ``_cursor_pos.y`` rows — right where rows stay in place,
short of the top on a reflowing terminal, whose re-wrapped chrome rows would stay
above the new paint. A row's width, re-wrapped, is its height now — if the terminal got
the row before it narrowed. prompt_toolkit writes rows with autowrap off, so one it got
after narrowing (painted just before the width change was seen, the signal still on its
way) was clipped to one row instead, and counting it as re-wrapped would erase
transcript rows above the chrome. Rows painted within ``_RESIZE_PAINT_MARGIN`` of the
width change, or unrecorded, count as one row: a stale chrome row left above the new
paint is benign, an erased transcript row is lost for good.
"""
renderer = app.renderer
screen, cursor = renderer._last_screen, renderer._cursor_pos
if screen is None or columns <= 0:
return
paints = getattr(self, "_chrome_paints", []) if getattr(self, "_chrome_paints_screen", None) is screen else []
cutoff = (getattr(self, "_resize_seen_at", None) or time.monotonic()) - _RESIZE_PAINT_MARGIN
rows = cursor.x // columns
for y in range(cursor.y):
history = paints[y] if y < len(paints) else []
settled = [i for i, (at, _width, suspect) in enumerate(history) if at < cutoff and not suspect]
if settled: # re-wrapped as it was then, or as a later, narrower paint reached it
rows += -(-min(width for _at, width, _suspect in history[settled[-1]:]) // columns)
else:
rows += 1
renderer._cursor_pos = cursor._replace(y=rows)
def _recover_after_resize(self, app, original_on_resize) -> None:
"""Recover a resized classic CLI without desynchronizing cursor state.
Never clears scrollback (the startup banner lives there and replay cannot rebuild
it) and never resets the renderer before prompt_toolkit's own ``_on_resize``,
which erases the chrome from the cached cursor position. The status bar / input
rules are suppressed while the reflow settles: on column shrink the terminal reflows
already-painted rows into scrollback first, so a fresh bar looks duplicated
(#19280, #22976). On a column shrink a reflowing terminal has re-wrapped the old
chrome into more rows than that cached cursor reaches, so the erase is aimed at the
re-wrapped top instead; the transcript above is left exactly as the terminal
re-wrapped it, never erased and replayed (a replay cannot know which rows the
terminal kept on screen and which it pushed into scrollback, #95375). A terminal that
does not reflow truncates every visible row at the new width instead and keeps it in
place, out of scrollback: the viewport is refilled as Ctrl+L does — also after a drag
that narrowed and widened back, which truncated rows all the same. When nothing says
whether the terminal reflows it is refilled too: a reflowing terminal had already
pushed some of those rows into scrollback and now shows them twice, which beats
truncating them for good.
With ``display.cli_rebuild_scrollback_on_redraw`` (3J + whole-history replay) every
width change rebuilds.
Same-width SIGWINCH (tmux attach, GNOME tab bar, focus) and the first signal
without a seeded baseline are left alone — 2J+replay against preserved scrollback
duplicates ``_OUTPUT_HISTORY`` (#65293). tmux-attach's stale previous_screen is
handled by ``_hermes_call_output_screen_diff`` (#83874). Suppression is cleared by
a debounced timer so the bar returns during idle; next-submit stays a fast path.
"""
try:
# A debounced composer resize can arrive after the alternate screen opens.
# Its erase/replay belongs to the suspended composer, not the monitor.
if getattr(getattr(self, '_subagent_monitor', None), 'opening', False):
return
from cli import _replay_output_history, _terminal_reflows
self._status_bar_suppressed_after_resize = True
try:
new_width = self._get_tui_terminal_width()
except Exception:
new_width = None
prev_width = getattr(self, "_last_resize_width", None)
narrowest = min(filter(None, (new_width, getattr(self, "_resize_narrowest", None))), default=None)
self._resize_narrowest = None
width_changed = new_width is not None and prev_width is not None and new_width != prev_width
narrowed = narrowest is not None and prev_width is not None and narrowest < prev_width
reflows = _terminal_reflows()
if width_changed and self._redraw_rebuilds_scrollback():
_replay_output_history(self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True))
elif reflows and width_changed and new_width < prev_width:
self._aim_erase_at_reflowed_chrome(app, new_width)
elif not reflows and narrowed:
fit = self._clear_prompt_toolkit_screen(app, keep_above=True)
_replay_output_history(fit, app.renderer.output)
if new_width is not None:
self._last_resize_width = new_width
if width_changed:
self._pet_queue_kitty_frame()
# Its redraw is skipped, and the next recovery scheduled, if the width moved again
# since it was read above (``_output_waits_for_resize``).
original_on_resize()
self._schedule_status_bar_unsuppress(app)
finally:
# Output held since the signal paints now, against the settled screen — unless
# another width change is already waiting for its own recovery.
if not getattr(self, "_resize_recovery_pending", False):
self._resize_hold_since = None
_release_paints()
def _restart_debounce_timer(self, attr: str, delay: float, fn) -> None:
"""Cancel the daemon Timer stored on ``self.<attr>`` (if any) and start a new one.
``fn`` receives the new Timer so it can detect being superseded."""
old_timer = getattr(self, attr, None)
if old_timer is not None:
try:
old_timer.cancel()
except Exception:
pass
timer = threading.Timer(delay, lambda: fn(timer))
timer.daemon = True
setattr(self, attr, timer)
timer.start()
def _schedule_status_bar_unsuppress(self, app, delay: float = 0.35) -> None:
"""Clear the post-resize status-bar suppression after the reflow settles.
Debounced: a fresh resize cancels the pending timer, so a resize storm repaints
the bar only once it stops.
"""
try:
def _clear():
self._status_bar_suppressed_after_resize = False
try:
app.invalidate()
except Exception:
pass
self._restart_debounce_timer(
"_status_bar_unsuppress_timer", delay, lambda _t: _run_on_app_loop(app, _clear))
except Exception:
# Fail open: never leave the bar stuck hidden.
self._status_bar_suppressed_after_resize = False
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.3) -> None:
"""Debounce resize redraws so footer chrome is not stamped into scrollback.
The delay outwaits tmux, which re-wraps a pane on every resize but signals it at most
every 250 ms, so mid-drag the width a recovery reads is stale — also over ssh from a
tmux pane, where nothing says tmux is there (#95375). A width change also holds output
paints and redraws until its recovery; while a drag keeps signalling, a recovery still
runs ``_RESIZE_SETTLE`` after the first width change once output was held for
``_RESIZE_HOLD_MAX`` seconds, so output never freezes for the whole drag. A signal that
leaves the width alone holds nothing.
"""
try:
lock = getattr(self, "_resize_recovery_lock", None)
if lock is None:
lock = threading.Lock()
self._resize_recovery_lock = lock
try:
width = app.output.get_size().columns
except Exception:
width = None
def _timer_fired(timer_ref):
def _run_recovery():
with lock:
if getattr(self, "_resize_recovery_timer", None) is not timer_ref:
return # superseded by a newer resize
self._resize_recovery_timer = None
self._resize_recovery_pending = False
self._recover_after_resize(app, original_on_resize)
_run_on_app_loop(app, _run_recovery)
with lock:
now = time.monotonic()
pending = getattr(self, "_resize_recovery_pending", False)
if pending or width is None or width != getattr(self, "_last_resize_width", None):
if not pending:
self._resize_recovery_pending = True
self._resize_seen_at = now
# A hold its last recovery could not release keeps its start: the
# next recovery is due at once.
if getattr(self, "_resize_hold_since", None) is None:
self._resize_hold_since = now
self._resize_narrowest = None
_hold_paints()
if isinstance(width, int) and width > 0: # truncated rows at the narrowest
self._resize_narrowest = min(width, getattr(self, "_resize_narrowest", None) or width)
if now - self._resize_hold_since >= _RESIZE_HOLD_MAX:
# Shortly after a width change, not on a timer: between two of a drag,
# once the terminal (a multiplexer re-wrapping its rows) has settled;
# one landing mid-recovery would move rows under its erase and replay.
delay = _RESIZE_SETTLE
self._restart_debounce_timer("_resize_recovery_timer", delay, _timer_fired)
except Exception:
self._resize_recovery_pending = False
self._recover_after_resize(app, original_on_resize)
def _install_resize_recovery(self, app) -> None:
"""Route ``app._on_resize`` through the debounced ghost-clearing recovery
(#5474/#49120) and seed the width baseline so the FIRST SIGWINCH can tell a benign
signal from a real resize (#65293). Reads ``app.output`` directly, NOT
``_get_tui_terminal_width``: before ``app.run()`` the DummyApplication's output
reports a hardcoded 80 columns, which would make the first real signal look like a
width change. ``app.output`` is what the running resize handler measures.
"""
width = None
for probe in (lambda: app.output.get_size().columns,
lambda: shutil.get_terminal_size((80, 24)).columns):
try:
width = probe()
except Exception:
width = None
if width and width > 0:
break
self._last_resize_width = width
original_on_resize = app._on_resize
self._resize_original_on_resize = original_on_resize
app._on_resize = lambda: self._schedule_resize_recovery(app, original_on_resize)
# A render before the recovery lands at the new width from the old geometry and strands
# chrome rows; the recovery repaints everything anyway (#95375). The final render at
# exit is never held.
original_redraw = app._redraw
def _redraw_unless_resizing(render_as_done: bool = False) -> None:
if render_as_done or not self._output_waits_for_resize(app):
renderer = app.renderer
floor = _chrome_floor()
if floor: # what CPR would tell prompt_toolkit: the rows down to the bottom
renderer._min_available_height = max(renderer._min_available_height, floor)
before = renderer._last_screen, renderer._last_size
original_redraw(render_as_done=render_as_done)
# prompt_toolkit repaints every row when it has no previous paint or a new size
self._note_chrome_paint(app, before[0] is None or renderer._last_size != before[1])
app._redraw = _redraw_unless_resizing
_set_paint_gate(app, lambda: self._output_waits_for_resize(app))
def _output_waits_for_resize(self, app) -> bool:
"""Whether output must wait for a resize recovery: one is pending, or the terminal's
width no longer matches the one the app last settled at — its SIGWINCH is not handled
yet, and a paint or chrome render now would land in geometry that no recovery accounts
for: on a terminal that does not reflow it scrolls rows the resize truncated into
scrollback before any refill (#95375). Noticing that schedules the recovery."""
if getattr(self, "_resize_recovery_pending", False):
return True
try:
width = app.output.get_size().columns
except Exception:
return False
if width == getattr(self, "_last_resize_width", None):
return False
self._schedule_resize_recovery(app, self._resize_original_on_resize)
return bool(getattr(self, "_resize_recovery_pending", False))
def _try_attach_clipboard_image(self) -> bool:
"""Save a clipboard image to ~/.hermes/images/ and attach it; True if attached."""
from cli import datetime
from hermes_cli.clipboard import save_clipboard_image
self._image_counter += 1
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
img_path = get_hermes_home() / "images" / f"clip_{ts}_{self._image_counter}.png"
if save_clipboard_image(img_path):
self._attached_images.append(img_path)
return True
self._image_counter -= 1
return False
def _write_osc52_clipboard(self, text: str) -> None:
"""Copy *text* to the terminal clipboard via OSC 52.
Wrapped for tmux/screen passthrough (mirrors ui-tui/src/lib/osc52.ts) — without
the DCS wrapper the multiplexer consumes the sequence and the copy is lost.
"""
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
seq = f"\x1b]52;c;{payload}\x07"
if os.environ.get("TMUX"):
seq = "\x1bPtmux;" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\"
elif os.environ.get("STY"):
seq = "\x1bP" + seq + "\x1b\\"
_write_terminal_sequence(getattr(self, "_app", None), seq)
def _recover_terminal_input_modes(self, *, reason: str) -> None:
"""Best-effort reset when leaked mouse reports indicate mode drift."""
from cli import (
CLI_CONFIG, _DIM, _RST, _TERMINAL_INPUT_MODE_RESET_SEQ,
_cli_multiline_shortcuts_enabled, _cprint, _enable_extended_enter_keys, logger)
now = time.monotonic()
# Rate-limit to avoid thrashing if a terminal floods reports. None = never
# (monotonic epoch is arbitrary, see _invalidate).
last = self._last_input_mode_recovery
if last is not None and now - last < 0.5:
return
self._last_input_mode_recovery = now
app = getattr(self, "_app", None)
output = getattr(app, "output", None) if app else None
try:
_write_terminal_sequence(app, _TERMINAL_INPUT_MODE_RESET_SEQ)
except Exception:
return
# The reset pops kitty keyboard mode and resets modifyOtherKeys too — re-request
# extended keys so Shift+Enter isn't silently dead for the rest of the session.
try:
if _cli_multiline_shortcuts_enabled(self.config or CLI_CONFIG):
_enable_extended_enter_keys(output)
except Exception:
pass
logger.warning("Recovered terminal input modes after leak: %s", reason)
if not self._input_mode_recovery_notice_shown:
self._input_mode_recovery_notice_shown = True
_cprint(f" {_DIM}{t('cli.terminal.recovered_mouse_modes')}{_RST}")
def _check_termios_drift(self) -> None:
"""Idle watchdog: heal a tty that drifted back to cooked mode (a lost
``run_in_terminal`` cooked→raw restore leaves it line-buffering while prompt_toolkit
believes it owns raw mode — CLI looks dead, process is healthy). Skipped while
``run_in_terminal`` legitimately holds cooked mode, while the agent runs
(approval/sudo prompts touch the tty), and on Windows (no termios).
"""
from cli import _DIM, _RST, _cprint, _heal_cooked_mode_drift, logger
if os.name == "nt":
return
app = getattr(self, "_app", None)
if app is None or not getattr(app, "_is_running", False):
return
if getattr(app, "_running_in_terminal", False):
return
now = time.monotonic()
last = self._last_termios_drift_check # None = never (monotonic epoch is arbitrary)
if last is not None and now - last < 1.0:
return
self._last_termios_drift_check = now
try:
if not sys.stdin.isatty():
return
fd = sys.stdin.fileno()
except Exception:
return
if _heal_cooked_mode_drift(fd):
logger.warning(
"Healed cooked-mode termios drift on stdin — a "
"run_in_terminal cooked→raw restore was lost.")
try:
self._invalidate() # so the prompt is visibly alive again
except Exception:
pass
if not self._termios_drift_notice_shown:
self._termios_drift_notice_shown = True
_cprint(f" {_DIM}{t('cli.terminal.recovered_cooked_mode')}{_RST}")