diff --git a/cli-config.yaml.example b/cli-config.yaml.example index a47f4783b3..c18759d2f2 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1768,17 +1768,12 @@ display: # false: Silent (default) bell_on_complete: false - # Play terminal bell when the agent asks a clarification question. - # Same mechanism as bell_on_complete (\\a) — works over SSH. - # true: Ring on every clarify prompt + # Play terminal bell when a blocking prompt opens and waits on you: + # clarify questions, dangerous-command approvals, sudo password, secret + # capture. Same mechanism as bell_on_complete (\a) — works over SSH. + # true: Ring whenever the agent is waiting for your input # false: Silent (default) - bell_on_clarify: false - - # Play terminal bell when a dangerous-command approval prompt opens. - # Same mechanism as bell_on_complete (\\a) — works over SSH. - # true: Ring on every approval prompt - # false: Silent (default) - bell_on_approval: false + bell_on_prompt: false # Show model reasoning/thinking before each response. # When enabled, a dim box shows the model's thought process above the response. diff --git a/cli.py b/cli.py index e4648fc258..a6e72e6519 100644 --- a/cli.py +++ b/cli.py @@ -5259,10 +5259,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.resume_display = CLI_CONFIG["display"].get("resume_display", "full") # bell_on_complete: play terminal bell (\a) when agent finishes a response self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False) - # bell_on_clarify: play terminal bell (\a) when agent asks a clarify question — same mechanism as bell_on_complete - self.bell_on_clarify = CLI_CONFIG["display"].get("bell_on_clarify", False) - # bell_on_approval: play terminal bell (\a) when a dangerous-command approval prompt opens — same mechanism as bell_on_complete - self.bell_on_approval = CLI_CONFIG["display"].get("bell_on_approval", False) + # bell_on_prompt: play terminal bell (\a) whenever a blocking prompt + # modal opens (clarify, approval, sudo password, secret capture) + self.bell_on_prompt = CLI_CONFIG["display"].get("bell_on_prompt", False) # show_reasoning: display model thinking/reasoning before the response self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", True) # reasoning_full: when reasoning display is on, print the post-response @@ -16168,6 +16167,23 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): outcome = outcome[:119] + "…" _cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}") + def _ring_bell(self, prompt: bool = False) -> None: + """Write a terminal bell (\\a) if the matching display.bell_* flag is on. + + ``prompt=True`` is the blocking-modal variant (clarify / approval / + sudo / secret capture) gated by ``display.bell_on_prompt``; the default + is the end-of-turn bell gated by ``display.bell_on_complete``. Works + over SSH — the BEL propagates to the user's terminal. + """ + flag = "bell_on_prompt" if prompt else "bell_on_complete" + if not getattr(self, flag, False): + return + try: + sys.stdout.write("\a") + sys.stdout.flush() + except Exception: + pass + def _clarify_callback(self, question, choices, multi_select=False, questions=None): """ Platform callback for the clarify tool. Called from the agent thread. @@ -16215,14 +16231,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_freetext = is_open_ended self._clarify_multi_base = None - # Bell on clarify (same mechanism as bell_on_complete — \\a over SSH) - if getattr(self, "bell_on_clarify", False): - try: - sys.stdout.write("\a") - sys.stdout.flush() - except Exception: - pass - + self._ring_bell(prompt=True) # Trigger an immediate prompt_toolkit repaint from this (non-main) # thread. Modal prompts must paint at once and must not be gated by the # _invalidate throttle / resize guard — see _paint_now / _invalidate (#41098). @@ -16414,12 +16423,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_state = state self._clarify_batch_set_active(state, 0) self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout - if getattr(self, "bell_on_clarify", False): - try: - sys.stdout.write("\a") - sys.stdout.flush() - except Exception: - pass + self._ring_bell(prompt=True) self._paint_now() _last_countdown_refresh = _time.monotonic() @@ -16470,6 +16474,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): "response_queue": response_queue, } self._sudo_deadline = _time.monotonic() + timeout + self._ring_bell(prompt=True) # Modal prompt — paint immediately, bypassing the throttle/resize guard # so the prompt can't be dropped and time out unseen (#41098). @@ -16539,14 +16544,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): } self._approval_deadline = _time.monotonic() + timeout - # Bell on approval (same mechanism as bell_on_complete — \a over SSH) - if getattr(self, "bell_on_approval", False): - try: - sys.stdout.write("\a") - sys.stdout.flush() - except Exception: - pass - + self._ring_bell(prompt=True) # Modal prompt — paint immediately, bypassing the throttle/resize # guard. A throttled paint here can be silently dropped (250ms # window collision or in-flight resize), leaving the panel unseen so @@ -17690,9 +17688,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Play terminal bell when agent finishes (if enabled). # Works over SSH — the bell propagates to the user's terminal. - if self.bell_on_complete: - sys.stdout.write("\a") - sys.stdout.flush() + self._ring_bell() # Notify when iteration budget was hit if result and not result.get("completed") and not result.get("interrupted"): diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index aad0542d28..58d1a8390e 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -120,6 +120,8 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: "response_queue": response_queue, } cli._secret_deadline = _time.monotonic() + timeout + if hasattr(cli, "_ring_bell"): + cli._ring_bell(prompt=True) # Avoid storing stale draft input as the secret when Enter is pressed. if hasattr(cli, "_clear_secret_input_buffer"): try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 09fb268c52..871d71e5a1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5136,7 +5136,10 @@ def show_config(): _active_personality = display.get('personality') or 'none' print(f" Personality: {_active_personality}") print(f" Reasoning: {'on' if display.get('show_reasoning', True) else 'off'}") - print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}") + print( + f" Bell: complete={'on' if display.get('bell_on_complete', False) else 'off'}, " + f"prompt={'on' if display.get('bell_on_prompt', False) else 'off'}" + ) ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {} ump_first = ump.get('first_lines', 2) ump_last = ump.get('last_lines', 2) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 9bc56a7bc7..c3e41886e0 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1475,8 +1475,8 @@ DEFAULT_CONFIG = { # dashboard. Set false to suppress the hint. "tui_agents_nudge": True, "bell_on_complete": False, - "bell_on_clarify": False, - "bell_on_approval": False, + # Bell when a blocking prompt opens (clarify/approval/sudo/secret). + "bell_on_prompt": False, # Stream the model's reasoning/thinking live before the response. # Default ON: on thinking models the reasoning phase can run tens of # seconds, and with this off the user stares at a spinner the whole diff --git a/tests/cli/test_cli_clarify_batch.py b/tests/cli/test_cli_clarify_batch.py index c839780bbb..a19575956c 100644 --- a/tests/cli/test_cli_clarify_batch.py +++ b/tests/cli/test_cli_clarify_batch.py @@ -341,3 +341,33 @@ class TestClarifyBatchNavigation: thread.join(timeout=2) assert result["value"] == {"answers": {"q0": "red", "q1": "small"}} + + +class TestClarifyBellOnPrompt: + """display.bell_on_prompt rings BEL when a clarify modal opens; off is silent.""" + + @staticmethod + def _run_clarify(bell_on_prompt): + import io + + cli = _make_cli_stub() + cli.bell_on_prompt = bell_on_prompt + out = io.StringIO() + with patch("cli.sys.stdout", out), patch( + "tools.clarify_gateway.resolve_clarify_timeout", return_value=60 + ): + thread = threading.Thread( + target=cli._clarify_callback, args=("Color?", ["red", "blue"]), daemon=True + ) + thread.start() + deadline = time.time() + 2 + while cli._clarify_state is None and time.time() < deadline: + time.sleep(0.01) + assert cli._clarify_state is not None + cli._clarify_state["response_queue"].put("red") + thread.join(timeout=2) + return out.getvalue() + + def test_bell_on_prompt_rings_and_off_is_silent(self): + assert "\a" in self._run_clarify(True) + assert "\a" not in self._run_clarify(False) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 9704eb149a..9e24da7517 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -420,7 +420,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const { rpc } = ctx.gateway const { STARTUP_RESUME_ID, newSession, recoverSidRef, resumeById, setCatalog } = ctx.session - const { bellOnApproval, bellOnClarify, bellOnComplete, stdout, sys } = ctx.system + const { bellOnComplete, bellOnPrompt, stdout, sys } = ctx.system + + // display.bell_on_prompt — BEL whenever a blocking prompt modal opens + // (same mechanism as bell_on_complete; works over SSH, triggers tmux bell-action). + const ringPromptBell = () => { + if (bellOnPrompt && stdout?.isTTY) { + stdout.write('\x07') + } + } + const { appendMessage, panel, setHistoryItems } = ctx.transcript const { setInput } = ctx.composer const { submitLiteralRef, submitRef } = ctx.submission @@ -1250,11 +1259,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } }) setStatus('waiting for input…') - - // Same BEL mechanism as bell_on_complete — works over SSH, triggers tmux bell-action - if (bellOnClarify && stdout?.isTTY) { - stdout.write('\x07') - } + ringPromptBell() return } @@ -1274,11 +1279,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } }) setStatus('approval needed') - - // Same BEL mechanism as bell_on_complete — works over SSH, triggers tmux bell-action - if (bellOnApproval && stdout?.isTTY) { - stdout.write('\x07') - } + ringPromptBell() return } @@ -1286,6 +1287,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'sudo.request': patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) setStatus('sudo password needed') + ringPromptBell() return @@ -1294,6 +1296,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } }) setStatus('secret input needed') + ringPromptBell() return diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index efa2a3452f..1aecedd70b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -498,8 +498,7 @@ export interface GatewayEventHandlerContext { } system: { bellOnComplete: boolean - bellOnClarify?: boolean - bellOnApproval?: boolean + bellOnPrompt?: boolean stdout?: NodeJS.WriteStream sys: (text: string) => void } diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 8effe2dbba..2f3f31dca3 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -254,11 +254,10 @@ export async function hydrateFullConfig( gw: GatewayClient, setBell: (v: boolean) => void, setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void, - setBellOnClarify?: (v: boolean) => void, - setBellOnApproval?: (v: boolean) => void + setBellOnPrompt?: (v: boolean) => void ): Promise { const cfg = await quietRpc(gw, 'config.get', { key: 'full' }) - applyDisplay(cfg, setBell, setVoiceRecordKey, setBellOnClarify, setBellOnApproval) + applyDisplay(cfg, setBell, setVoiceRecordKey, setBellOnPrompt) return cfg } @@ -267,21 +266,14 @@ export const applyDisplay = ( cfg: ConfigFullResponse | null, setBell: (v: boolean) => void, setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void, - setBellOnClarify?: (v: boolean) => void, - setBellOnApproval?: (v: boolean) => void + setBellOnPrompt?: (v: boolean) => void ) => { const d = cfg?.config?.display ?? {} const approvals = cfg?.config?.approvals setBell(!!d.bell_on_complete) - if (setBellOnClarify) { - setBellOnClarify(!!d.bell_on_clarify) - } - - if (setBellOnApproval) { - setBellOnApproval(!!d.bell_on_approval) - } + setBellOnPrompt?.(!!d.bell_on_prompt) applyConfiguredTuiTheme(d.tui_theme) @@ -326,8 +318,7 @@ export const applyDisplay = ( export function useConfigSync({ gw, setBellOnComplete, - setBellOnClarify, - setBellOnApproval, + setBellOnPrompt, setVoiceEnabled, setVoiceRecordKey, sid @@ -353,8 +344,8 @@ export function useConfigSync({ // mcp_rev) look like an MCP change and fire a needless reload.mcp. mcpRevRef.current.accepted = String(r?.mcp_rev ?? '') }) - void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnClarify, setBellOnApproval) - }, [gw, setBellOnComplete, setBellOnClarify, setBellOnApproval, setVoiceEnabled, setVoiceRecordKey, sid]) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnPrompt) + }, [gw, setBellOnComplete, setBellOnPrompt, setVoiceEnabled, setVoiceRecordKey, sid]) useEffect(() => { if (!sid) { @@ -401,19 +392,18 @@ export function useConfigSync({ ) } - void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnClarify, setBellOnApproval) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnPrompt) }) }, MTIME_POLL_MS) return () => clearInterval(id) - }, [gw, setBellOnComplete, setBellOnClarify, setBellOnApproval, setVoiceRecordKey, sid]) + }, [gw, setBellOnComplete, setBellOnPrompt, setVoiceRecordKey, sid]) } export interface UseConfigSyncOptions { gw: GatewayClient setBellOnComplete: (v: boolean) => void - setBellOnClarify?: (v: boolean) => void - setBellOnApproval?: (v: boolean) => void + setBellOnPrompt?: (v: boolean) => void setVoiceEnabled: (v: boolean) => void setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void sid: null | string diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7bac7a5305..7d57382644 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -207,8 +207,7 @@ export function useMainApp(gw: GatewayClient) { // Bumped by the gateway `reaction` event (core-detected affection). const goodVibesTick = useStore($goodVibesTick) const [bellOnComplete, setBellOnComplete] = useState(false) - const [bellOnClarify, setBellOnClarify] = useState(false) - const [bellOnApproval, setBellOnApproval] = useState(false) + const [bellOnPrompt, setBellOnPrompt] = useState(false) const ui = useStore($uiState) const overlay = useStore($overlayState) @@ -580,7 +579,7 @@ export function useMainApp(gw: GatewayClient) { } }, [ui.busy, turnStartedAt]) - useConfigSync({ gw, setBellOnComplete, setBellOnClarify, setBellOnApproval, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) + useConfigSync({ gw, setBellOnComplete, setBellOnPrompt, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) useBatteryPoll(gw) useEffect(() => { @@ -859,7 +858,7 @@ export function useMainApp(gw: GatewayClient) { setCatalog }, submission: { submitLiteralRef, submitRef }, - system: { bellOnComplete, bellOnClarify, bellOnApproval, stdout, sys }, + system: { bellOnComplete, bellOnPrompt, stdout, sys }, transcript: { appendMessage, panel, setHistoryItems }, voice: { setProcessing: setVoiceProcessing, @@ -870,9 +869,8 @@ export function useMainApp(gw: GatewayClient) { }), [ appendMessage, - bellOnApproval, - bellOnClarify, bellOnComplete, + bellOnPrompt, composerActions.setInput, gateway, panel, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8ec5d76bee..b2c2fd5955 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -79,8 +79,7 @@ export type CommandDispatchResponse = export interface ConfigDisplayConfig { battery?: boolean bell_on_complete?: boolean - bell_on_clarify?: boolean - bell_on_approval?: boolean + bell_on_prompt?: boolean busy_input_mode?: string details_mode?: string /** Focus view (/focus) — display-only reduced-output mode. */ diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ae7b69d091..6eca90e672 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1909,8 +1909,7 @@ display: cli_multiline_shortcuts: true # CLI: Ctrl+J, \ + Enter, and supported Shift+Enter insert newlines (false = legacy c-j submit fallback) resume_display: full # full (show previous messages on resume) | minimal (one-liner only) bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks) - bell_on_clarify: false # Play terminal bell when the agent asks a clarification question (same BEL mechanism, works over SSH) - bell_on_approval: false # Play terminal bell when a dangerous-command approval prompt opens (same BEL mechanism, works over SSH) + bell_on_prompt: false # Play terminal bell when a blocking prompt opens (clarify, approval, sudo password, secret capture) — works over SSH show_reasoning: true # Show model reasoning/thinking above each response (default: true; toggle with /reasoning show|hide) streaming: false # Stream tokens to terminal as they arrive (real-time output) show_cost: false # Show estimated $ cost in the CLI status bar