mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): Shift+click the status-bar zap to toggle YOLO globally (#41666)
The status-bar zap currently toggles per-session approval bypass (the same scope as the TUI's Shift+Tab). This adds a global escape hatch: Shift+clicking the zap flips the persistent approvals.mode in config.yaml between "off" (bypass on) and "manual" (bypass off), affecting every session, the CLI, the TUI, and cron — and it survives restarts. - statusbar-controls: thread the click's shiftKey through onSelect via a new StatusbarSelectModifiers arg. - yolo-session: add setGlobalYolo() that calls config.set with scope="global". - use-statusbar-items: branch toggleYolo on modifiers.shiftKey; plain click stays per-session, Shift+click goes global. - tui_gateway config.set "yolo" key: add scope="global" that reads/writes approvals.mode through the gateway's own (mtime-cached) config view, honors an explicit value, and re-emits session.info to every live session so each window's zap reflects the flip immediately. - i18n: tooltip copy in en/ja/zh/zh-hant notes Shift+click toggles globally. Tests: two new tui_gateway tests cover the global toggle and explicit-value paths; existing session/process-scope yolo tests still pass.
This commit is contained in:
parent
30c7913617
commit
fa42ac094d
9 changed files with 188 additions and 52 deletions
|
|
@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
|
|||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
|
|
@ -16,12 +17,11 @@ import {
|
|||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
|
|
@ -44,7 +44,7 @@ import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } fr
|
|||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
|
|
@ -105,22 +105,39 @@ export function useStatusbarItems({
|
|||
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
|
||||
// new-chat draft (no runtime session yet) we arm locally; the session-create
|
||||
// path applies it once the backend session exists.
|
||||
const toggleYolo = useCallback(async () => {
|
||||
const next = !$yoloActive.get()
|
||||
const sid = $activeSessionId.get()
|
||||
//
|
||||
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
|
||||
// all-sessions/CLI/TUI/cron bypass that survives restarts.
|
||||
const toggleYolo = useCallback(
|
||||
async (modifiers?: StatusbarSelectModifiers) => {
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
setYoloActive(next)
|
||||
setYoloActive(next)
|
||||
|
||||
if (!sid) {
|
||||
return
|
||||
}
|
||||
if (modifiers?.shiftKey) {
|
||||
try {
|
||||
await setGlobalYolo(requestGateway, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionYolo(requestGateway, sid, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
}, [requestGateway])
|
||||
return
|
||||
}
|
||||
|
||||
const sid = $activeSessionId.get()
|
||||
|
||||
if (!sid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionYolo(requestGateway, sid, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
|
||||
|
||||
|
|
@ -333,7 +350,7 @@ export function useStatusbarItems({
|
|||
<Zap className="size-3.5 shrink-0 opacity-70" />
|
||||
),
|
||||
id: 'yolo',
|
||||
onSelect: () => void toggleYolo(),
|
||||
onSelect: modifiers => void toggleYolo(modifiers),
|
||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,12 +35,16 @@ export interface StatusbarItem {
|
|||
menuClassName?: string
|
||||
menuContent?: ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
onSelect?: () => void
|
||||
onSelect?: (modifiers: StatusbarSelectModifiers) => void
|
||||
title?: string
|
||||
to?: string
|
||||
variant?: 'action' | 'link' | 'menu' | 'text'
|
||||
}
|
||||
|
||||
export interface StatusbarSelectModifiers {
|
||||
shiftKey: boolean
|
||||
}
|
||||
|
||||
export type StatusbarItemSide = 'left' | 'right'
|
||||
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
|
||||
|
||||
|
|
@ -170,12 +174,12 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
onClick={event => {
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
}
|
||||
|
||||
item.onSelect?.()
|
||||
item.onSelect?.({ shiftKey: event.shiftKey })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1463,8 +1463,8 @@ export const en: Translations = {
|
|||
contextUsage: 'Context usage',
|
||||
session: 'Session',
|
||||
runtimeSessionElapsed: 'Runtime session elapsed',
|
||||
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
|
||||
yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
|
||||
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',
|
||||
yoloOff: 'YOLO off — click to auto-approve dangerous commands. Shift+click toggles it globally.',
|
||||
modelNone: 'none',
|
||||
noModel: 'no model',
|
||||
switchModel: 'Switch model',
|
||||
|
|
|
|||
|
|
@ -1606,8 +1606,8 @@ export const ja = defineLocale({
|
|||
contextUsage: 'コンテキスト使用状況',
|
||||
session: 'セッション',
|
||||
runtimeSessionElapsed: 'ランタイムセッション経過時間',
|
||||
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。',
|
||||
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。',
|
||||
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',
|
||||
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。Shift+クリックで全体に切り替え。',
|
||||
modelNone: 'なし',
|
||||
noModel: 'モデルなし',
|
||||
switchModel: 'モデルを切り替え',
|
||||
|
|
|
|||
|
|
@ -1567,8 +1567,8 @@ export const zhHant = defineLocale({
|
|||
contextUsage: '上下文使用量',
|
||||
session: '工作階段',
|
||||
runtimeSessionElapsed: '執行時工作階段已用時間',
|
||||
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。',
|
||||
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。',
|
||||
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',
|
||||
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。Shift+點擊可全域切換。',
|
||||
modelNone: '無',
|
||||
noModel: '無模型',
|
||||
switchModel: '切換模型',
|
||||
|
|
|
|||
|
|
@ -1644,8 +1644,8 @@ export const zh: Translations = {
|
|||
contextUsage: '上下文用量',
|
||||
session: '会话',
|
||||
runtimeSessionElapsed: '运行时会话已用时间',
|
||||
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。',
|
||||
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。',
|
||||
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',
|
||||
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。Shift+点击可全局切换。',
|
||||
modelNone: '无',
|
||||
noModel: '无模型',
|
||||
switchModel: '切换模型',
|
||||
|
|
|
|||
|
|
@ -24,3 +24,27 @@ export async function setSessionYolo(
|
|||
|
||||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle GLOBAL YOLO (approval bypass) via gateway `config.set` with
|
||||
* `scope: 'global'`. This flips the persistent `approvals.mode` in config.yaml
|
||||
* between `off` (bypass on) and `manual` (bypass off), affecting every session,
|
||||
* the CLI, the TUI, and cron — and it survives restarts. Triggered by
|
||||
* Shift+clicking the status-bar zap.
|
||||
*/
|
||||
export async function setGlobalYolo(
|
||||
requestGateway: GatewayRequester,
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
const result = await requestGateway<{ value?: string }>('config.set', {
|
||||
key: 'yolo',
|
||||
scope: 'global',
|
||||
value: enabled ? '1' : '0'
|
||||
})
|
||||
|
||||
const active = result?.value === '1'
|
||||
|
||||
setYoloActive(active)
|
||||
|
||||
return active
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1454,6 +1454,66 @@ def test_config_set_yolo_toggles_session_scope():
|
|||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatch):
|
||||
"""Shift+click the desktop zap -> scope="global" flips persistent approvals.mode."""
|
||||
import yaml
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}}))
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
|
||||
resp_on = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "yolo", "scope": "global"},
|
||||
}
|
||||
)
|
||||
assert resp_on["result"]["value"] == "1"
|
||||
assert resp_on["result"]["scope"] == "global"
|
||||
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"
|
||||
|
||||
resp_off = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"key": "yolo", "scope": "global"},
|
||||
}
|
||||
)
|
||||
assert resp_off["result"]["value"] == "0"
|
||||
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual"
|
||||
|
||||
|
||||
def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch):
|
||||
"""An explicit value pins global approvals.mode regardless of prior state."""
|
||||
import yaml
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}}))
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "yolo", "scope": "global", "value": "1"},
|
||||
}
|
||||
)
|
||||
assert resp["result"]["value"] == "1"
|
||||
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"
|
||||
|
||||
# Setting it on again is idempotent — stays off.
|
||||
resp_again = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"key": "yolo", "scope": "global", "value": "1"},
|
||||
}
|
||||
)
|
||||
assert resp_again["result"]["value"] == "1"
|
||||
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "off"
|
||||
|
||||
|
||||
def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
|
||||
writes = []
|
||||
emits = []
|
||||
|
|
|
|||
|
|
@ -5785,32 +5785,62 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key == "yolo":
|
||||
# Per-session approval bypass — same scope as the TUI's Shift+Tab. This
|
||||
# toggles ONLY this session's _session_yolo flag; it never writes the
|
||||
# global approvals.mode, so it cannot change CLI / TUI / cron behavior.
|
||||
# Approval bypass. Two scopes:
|
||||
# scope="session" (default) — same as the TUI's Shift+Tab. Toggles
|
||||
# ONLY this session's _session_yolo flag; never touches global
|
||||
# config, so CLI / TUI / cron behavior is unaffected.
|
||||
# scope="global" (Shift+click the zap) — flips the persistent global
|
||||
# approvals.mode in config.yaml between "off" (bypass on) and
|
||||
# "manual" (bypass off). This DOES affect every session, the CLI,
|
||||
# the TUI, and cron, and survives restarts.
|
||||
scope = str(params.get("scope") or "session").strip().lower()
|
||||
try:
|
||||
if session:
|
||||
from tools.approval import (
|
||||
disable_session_yolo,
|
||||
enable_session_yolo,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
from tools.approval import (
|
||||
disable_session_yolo,
|
||||
enable_session_yolo,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
|
||||
raw = str(value or "").strip().lower()
|
||||
raw = str(value or "").strip().lower()
|
||||
|
||||
def _resolve_toggle(current: bool) -> bool:
|
||||
if raw in {"1", "on", "true", "yes"}:
|
||||
return True
|
||||
if raw in {"0", "off", "false", "no"}:
|
||||
return False
|
||||
return not current
|
||||
|
||||
if scope == "global":
|
||||
from tools.approval import _normalize_approval_mode
|
||||
|
||||
cfg = _load_cfg()
|
||||
appr = cfg.get("approvals") if isinstance(cfg, dict) else None
|
||||
if not isinstance(appr, dict):
|
||||
appr = {}
|
||||
current = _normalize_approval_mode(appr.get("mode", "manual")) == "off"
|
||||
enable = _resolve_toggle(current)
|
||||
# Toggle between full bypass and the default manual gate. We do
|
||||
# not try to restore a prior "smart"/custom mode — the zap is a
|
||||
# binary on/off affordance; users with bespoke modes set them in
|
||||
# config.yaml.
|
||||
_write_config_key("approvals.mode", "off" if enable else "manual")
|
||||
nv = "1" if enable else "0"
|
||||
# Reflect the global flip in every live session's indicator.
|
||||
for sid, sess in list(_sessions.items()):
|
||||
agent = sess.get("agent")
|
||||
if agent is not None:
|
||||
_emit("session.info", sid, _session_info(agent, sess))
|
||||
return _ok(rid, {"key": key, "value": nv, "scope": "global"})
|
||||
|
||||
if session:
|
||||
current = is_session_yolo_enabled(session["session_key"])
|
||||
enable = _resolve_toggle(current)
|
||||
if enable:
|
||||
enable_session_yolo(session["session_key"])
|
||||
nv = "1"
|
||||
elif raw in {"0", "off", "false", "no"}:
|
||||
else:
|
||||
disable_session_yolo(session["session_key"])
|
||||
nv = "0"
|
||||
else:
|
||||
current = is_session_yolo_enabled(session["session_key"])
|
||||
if current:
|
||||
disable_session_yolo(session["session_key"])
|
||||
nv = "0"
|
||||
else:
|
||||
enable_session_yolo(session["session_key"])
|
||||
nv = "1"
|
||||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
_emit(
|
||||
|
|
@ -5820,13 +5850,14 @@ def _(rid, params: dict) -> dict:
|
|||
)
|
||||
else:
|
||||
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
nv = "0"
|
||||
else:
|
||||
enable = _resolve_toggle(current)
|
||||
if enable:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
nv = "1"
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
else:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
nv = "0"
|
||||
return _ok(rid, {"key": key, "value": nv, "scope": "session"})
|
||||
except Exception as e:
|
||||
return _err(rid, 5001, str(e))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue