Merge pull request #14145 from NousResearch/bb/tui-polish
fix(tui): input wrap, shift-tab yolo, statusline, clean boot
This commit is contained in:
@@ -332,24 +332,30 @@ class LogSnapshot:
|
||||
tail_text: str
|
||||
full_text: Optional[str]
|
||||
|
||||
def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Find the log file for *log_name*, falling back to the .1 rotation.
|
||||
|
||||
Returns the path if found, or None.
|
||||
"""
|
||||
def _primary_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Where *log_name* would live if present. Doesn't check existence."""
|
||||
from hermes_cli.logs import LOG_FILES
|
||||
|
||||
filename = LOG_FILES.get(log_name)
|
||||
if not filename:
|
||||
return (get_hermes_home() / "logs" / filename) if filename else None
|
||||
|
||||
|
||||
def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Find the log file for *log_name*, falling back to the .1 rotation.
|
||||
|
||||
Returns the first non-empty candidate (primary, then .1), or None.
|
||||
Callers distinguish 'empty primary' from 'truly missing' via
|
||||
:func:`_primary_log_path`.
|
||||
"""
|
||||
primary = _primary_log_path(log_name)
|
||||
if primary is None:
|
||||
return None
|
||||
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
primary = log_dir / filename
|
||||
if primary.exists() and primary.stat().st_size > 0:
|
||||
return primary
|
||||
|
||||
# Fall back to the most recent rotated file (.1).
|
||||
rotated = log_dir / f"{filename}.1"
|
||||
rotated = primary.parent / f"{primary.name}.1"
|
||||
if rotated.exists() and rotated.stat().st_size > 0:
|
||||
return rotated
|
||||
|
||||
@@ -370,12 +376,15 @@ def _capture_log_snapshot(
|
||||
"""
|
||||
log_path = _resolve_log_path(log_name)
|
||||
if log_path is None:
|
||||
return LogSnapshot(path=None, tail_text="(file not found)", full_text=None)
|
||||
primary = _primary_log_path(log_name)
|
||||
tail = "(file empty)" if primary and primary.exists() else "(file not found)"
|
||||
return LogSnapshot(path=None, tail_text=tail, full_text=None)
|
||||
|
||||
try:
|
||||
size = log_path.stat().st_size
|
||||
if size == 0:
|
||||
return LogSnapshot(path=log_path, tail_text="(file not found)", full_text=None)
|
||||
# race: file was truncated between _resolve_log_path and stat
|
||||
return LogSnapshot(path=log_path, tail_text="(file empty)", full_text=None)
|
||||
|
||||
with open(log_path, "rb") as f:
|
||||
if size <= max_bytes:
|
||||
|
||||
@@ -158,14 +158,27 @@ class TestCaptureLogSnapshot:
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file not found)"
|
||||
|
||||
def test_returns_none_for_empty(self, hermes_home):
|
||||
# Truncate agent.log to empty
|
||||
def test_empty_primary_reports_file_empty(self, hermes_home):
|
||||
"""Empty primary (no .1 fallback) surfaces as '(file empty)', not missing."""
|
||||
(hermes_home / "logs" / "agent.log").write_text("")
|
||||
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file not found)"
|
||||
assert snap.tail_text == "(file empty)"
|
||||
|
||||
def test_race_truncate_after_resolve_reports_empty(self, hermes_home, monkeypatch):
|
||||
"""If the log is truncated between resolve and stat, say 'empty', not 'missing'."""
|
||||
log_path = hermes_home / "logs" / "agent.log"
|
||||
from hermes_cli import debug
|
||||
|
||||
monkeypatch.setattr(debug, "_resolve_log_path", lambda _name: log_path)
|
||||
log_path.write_text("")
|
||||
|
||||
snap = debug._capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.path == log_path
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file empty)"
|
||||
|
||||
def test_truncates_large_file(self, hermes_home):
|
||||
"""Files larger than max_bytes get tail-truncated."""
|
||||
|
||||
@@ -106,11 +106,23 @@ def test_config_set_yolo_toggles_session_scope():
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
try:
|
||||
resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
resp_on = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "yolo"},
|
||||
}
|
||||
)
|
||||
assert resp_on["result"]["value"] == "1"
|
||||
assert is_session_yolo_enabled("session-key") is True
|
||||
|
||||
resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
resp_off = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "yolo"},
|
||||
}
|
||||
)
|
||||
assert resp_off["result"]["value"] == "0"
|
||||
assert is_session_yolo_enabled("session-key") is False
|
||||
finally:
|
||||
@@ -118,6 +130,36 @@ def test_config_set_yolo_toggles_session_scope():
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_config_get_statusbar_survives_non_dict_display(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "statusbar"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "top"
|
||||
|
||||
|
||||
def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"display": "broken"}))
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "statusbar", "value": "bottom"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "bottom"
|
||||
saved = yaml.safe_load(cfg_path.read_text())
|
||||
assert saved["display"]["tui_statusbar"] == "bottom"
|
||||
|
||||
|
||||
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
@@ -144,13 +186,21 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp_effort = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "reasoning", "value": "low"},
|
||||
}
|
||||
)
|
||||
assert resp_effort["result"]["value"] == "low"
|
||||
assert agent.reasoning_config == {"enabled": True, "effort": "low"}
|
||||
|
||||
resp_show = server.handle_request(
|
||||
{"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}}
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "reasoning", "value": "show"},
|
||||
}
|
||||
)
|
||||
assert resp_show["result"]["value"] == "show"
|
||||
assert server._sessions["sid"]["show_reasoning"] is True
|
||||
@@ -162,7 +212,11 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "verbose", "value": "cycle"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "verbose"
|
||||
@@ -180,7 +234,11 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "new/model"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "new/model"
|
||||
@@ -221,7 +279,15 @@ def test_config_set_model_global_persists(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "anthropic/claude-sonnet-4.6 --global",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
|
||||
@@ -241,6 +307,7 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch):
|
||||
trying openrouter because the env-var-backed resolvers still saw the old
|
||||
provider.
|
||||
"""
|
||||
|
||||
class _Agent:
|
||||
provider = "openrouter"
|
||||
model = "old/model"
|
||||
@@ -262,21 +329,39 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_switch.switch_model", lambda **_kwargs: result
|
||||
)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
|
||||
server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "claude-sonnet-4.6 --provider anthropic"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "claude-sonnet-4.6 --provider anthropic",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic"
|
||||
|
||||
|
||||
def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_available_personalities",
|
||||
lambda cfg=None: {"helpful": "You are helpful."},
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "personality", "value": "bogus"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
@@ -284,20 +369,36 @@ def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
|
||||
|
||||
def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4)
|
||||
session = _session(
|
||||
agent=types.SimpleNamespace(),
|
||||
history=[{"role": "user", "text": "hi"}],
|
||||
history_version=4,
|
||||
)
|
||||
new_agent = types.SimpleNamespace(model="x")
|
||||
emits = []
|
||||
|
||||
server._sessions["sid"] = session
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_available_personalities",
|
||||
lambda cfg=None: {"helpful": "You are helpful."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_make_agent", lambda sid, key, session_id=None: new_agent
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}
|
||||
)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "personality", "value": "helpful"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["history_reset"] is True
|
||||
@@ -311,11 +412,17 @@ def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
agent = types.SimpleNamespace()
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42}))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_compress_session_history",
|
||||
lambda session, focus_topic=None: (2, {"total": 42}),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
|
||||
with patch("tui_gateway.server._emit") as emit:
|
||||
resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["removed"] == 2
|
||||
assert resp["result"]["usage"]["total"] == 42
|
||||
@@ -328,9 +435,14 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
captured["session_key"] = get_current_session_key(default="")
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -345,7 +457,13 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "ping"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["status"] == "streaming"
|
||||
assert captured["session_key"] == "session-key"
|
||||
@@ -359,9 +477,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
captured["prompt"] = prompt
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -371,8 +494,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
self._target()
|
||||
|
||||
fake_ctx = types.ModuleType("agent.context_references")
|
||||
fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace(
|
||||
blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0
|
||||
fake_ctx.preprocess_context_references = (
|
||||
lambda message, **kwargs: types.SimpleNamespace(
|
||||
blocked=False,
|
||||
message="expanded prompt",
|
||||
warnings=[],
|
||||
references=[],
|
||||
injected_tokens=0,
|
||||
)
|
||||
)
|
||||
fake_meta = types.ModuleType("agent.model_metadata")
|
||||
fake_meta.get_model_context_length = lambda *args, **kwargs: 100000
|
||||
@@ -385,7 +514,13 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx)
|
||||
monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta)
|
||||
|
||||
server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}})
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "@diff"},
|
||||
}
|
||||
)
|
||||
|
||||
assert captured["prompt"] == "expanded prompt"
|
||||
|
||||
@@ -404,7 +539,13 @@ def test_image_attach_appends_local_image(monkeypatch):
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach",
|
||||
"params": {"session_id": "sid", "path": "/tmp/cat.png"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["attached"] is True
|
||||
assert resp["result"]["name"] == "cat.png"
|
||||
@@ -420,14 +561,21 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
|
||||
"is_image": True,
|
||||
"remainder": "",
|
||||
}
|
||||
fake_cli._split_path_input = lambda raw: ("/tmp/Screenshot", "2026-04-21 at 1.04.43 PM.png")
|
||||
fake_cli._split_path_input = lambda raw: (
|
||||
"/tmp/Screenshot",
|
||||
"2026-04-21 at 1.04.43 PM.png",
|
||||
)
|
||||
fake_cli._resolve_attachment_path = lambda raw: None
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": str(screenshot)}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach",
|
||||
"params": {"session_id": "sid", "path": str(screenshot)},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["attached"] is True
|
||||
@@ -437,20 +585,34 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
|
||||
|
||||
|
||||
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
|
||||
"build": {"type": "exec", "command": "npm run build"},
|
||||
"git": {"type": "alias", "target": "/shell git"},
|
||||
"notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"},
|
||||
}})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {
|
||||
"quick_commands": {
|
||||
"build": {"type": "exec", "command": "npm run build"},
|
||||
"git": {"type": "alias", "target": "/shell git"},
|
||||
"notes": {
|
||||
"type": "exec",
|
||||
"command": "cat NOTES.md",
|
||||
"description": "Open design notes",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "commands.catalog", "params": {}}
|
||||
)
|
||||
|
||||
pairs = dict(resp["result"]["pairs"])
|
||||
assert "npm run build" in pairs["/build"]
|
||||
assert pairs["/git"].startswith("alias →")
|
||||
assert pairs["/notes"] == "Open design notes"
|
||||
|
||||
user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands")
|
||||
user_cat = next(
|
||||
c for c in resp["result"]["categories"] if c["name"] == "User commands"
|
||||
)
|
||||
user_pairs = dict(user_cat["pairs"])
|
||||
assert set(user_pairs) == {"/build", "/git", "/notes"}
|
||||
|
||||
@@ -459,14 +621,22 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||
|
||||
|
||||
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"),
|
||||
lambda *args, **kwargs: types.SimpleNamespace(
|
||||
returncode=1, stdout="", stderr="failed"
|
||||
),
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "failed" in resp["error"]["message"]
|
||||
@@ -474,15 +644,22 @@ def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
|
||||
def test_plugins_list_surfaces_loader_error(monkeypatch):
|
||||
with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")):
|
||||
resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "plugins.list", "params": {}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "boom" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_complete_slash_surfaces_completer_error(monkeypatch):
|
||||
with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")):
|
||||
resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}})
|
||||
with patch(
|
||||
"hermes_cli.commands.SlashCommandCompleter",
|
||||
side_effect=Exception("no completer"),
|
||||
):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "no completer" in resp["error"]["message"]
|
||||
@@ -500,7 +677,11 @@ def test_input_detect_drop_attaches_image(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "input.detect_drop",
|
||||
"params": {"session_id": "sid", "text": "/tmp/cat.png"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["matched"] is True
|
||||
@@ -521,7 +702,9 @@ def test_rollback_restore_resolves_number_and_file_path():
|
||||
calls["args"] = (cwd, target, file_path)
|
||||
return {"success": True, "message": "done"}
|
||||
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[])
|
||||
server._sessions["sid"] = _session(
|
||||
agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
@@ -572,7 +755,9 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it():
|
||||
|
||||
|
||||
def test_session_steer_rejects_empty_text():
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(steer=lambda t: True))
|
||||
server._sessions["sid"] = _session(
|
||||
agent=types.SimpleNamespace(steer=lambda t: True)
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
@@ -632,10 +817,13 @@ def test_session_undo_rejects_while_running():
|
||||
"""Fix for TUI silent-drop #1: /undo must not mutate history
|
||||
while the agent is mid-turn — would either clobber the undo or
|
||||
cause prompt.submit to silently drop the agent's response."""
|
||||
server._sessions["sid"] = _session(running=True, history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
server._sessions["sid"] = _session(
|
||||
running=True,
|
||||
history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
],
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
|
||||
@@ -651,10 +839,13 @@ def test_session_undo_rejects_while_running():
|
||||
|
||||
def test_session_undo_allowed_when_idle():
|
||||
"""Regression guard: when not running, /undo still works."""
|
||||
server._sessions["sid"] = _session(running=False, history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
server._sessions["sid"] = _session(
|
||||
running=False,
|
||||
history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
],
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
|
||||
@@ -683,7 +874,11 @@ def test_rollback_restore_rejects_full_history_while_running(monkeypatch):
|
||||
server._sessions["sid"] = _session(running=True)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "rollback.restore", "params": {"session_id": "sid", "hash": "abc"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "rollback.restore",
|
||||
"params": {"session_id": "sid", "hash": "abc"},
|
||||
}
|
||||
)
|
||||
assert resp.get("error"), "full-history rollback should reject while running"
|
||||
assert resp["error"]["code"] == 4009
|
||||
@@ -701,12 +896,17 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
session_ref = {"s": None}
|
||||
|
||||
class _RacyAgent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
# Simulate: something external bumped history_version
|
||||
# while we were running.
|
||||
with session_ref["s"]["history_lock"]:
|
||||
session_ref["s"]["history_version"] += 1
|
||||
return {"final_response": "agent reply", "messages": [{"role": "assistant", "content": "agent reply"}]}
|
||||
return {
|
||||
"final_response": "agent reply",
|
||||
"messages": [{"role": "assistant", "content": "agent reply"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -725,7 +925,11 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
|
||||
@@ -742,16 +946,25 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
"history_version mismatch — otherwise the UI silently "
|
||||
"shows output that was never persisted"
|
||||
)
|
||||
assert "not saved" in payload["warning"].lower() or "changed" in payload["warning"].lower()
|
||||
assert (
|
||||
"not saved" in payload["warning"].lower()
|
||||
or "changed" in payload["warning"].lower()
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
|
||||
"""Regression guard: the backstop does not affect the happy path."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
return {"final_response": "reply", "messages": [{"role": "assistant", "content": "reply"}]}
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": "reply",
|
||||
"messages": [{"role": "assistant", "content": "reply"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -769,12 +982,18 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
|
||||
monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
|
||||
# History was written
|
||||
assert server._sessions["sid"]["history"] == [{"role": "assistant", "content": "reply"}]
|
||||
assert server._sessions["sid"]["history"] == [
|
||||
{"role": "assistant", "content": "reply"}
|
||||
]
|
||||
assert server._sessions["sid"]["history_version"] == 1
|
||||
|
||||
# No warning should be attached
|
||||
@@ -818,7 +1037,11 @@ def test_interrupt_only_clears_own_session_pending():
|
||||
|
||||
# Interrupt session A.
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.interrupt",
|
||||
"params": {"session_id": "sid_a"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
|
||||
@@ -891,8 +1114,11 @@ def test_respond_unpacks_sid_tuple_correctly():
|
||||
server._pending["rid-x"] = ("sid_x", ev)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "clarify.respond",
|
||||
"params": {"request_id": "rid-x", "answer": "the answer"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "clarify.respond",
|
||||
"params": {"request_id": "rid-x", "answer": "the answer"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert ev.is_set()
|
||||
@@ -902,7 +1128,6 @@ def test_respond_unpacks_sid_tuple_correctly():
|
||||
server._answers.pop("rid-x", None)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model switch and other agent-mutating commands must reject while the
|
||||
# session is running. agent.switch_model() mutates self.model, self.provider,
|
||||
@@ -925,10 +1150,17 @@ def test_config_set_model_rejects_while_running(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(running=True)
|
||||
try:
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6"},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert resp.get("error")
|
||||
assert resp["error"]["code"] == 4009
|
||||
assert "session busy" in resp["error"]["message"]
|
||||
@@ -952,10 +1184,13 @@ def test_config_set_model_allowed_when_idle(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(running=False)
|
||||
try:
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert resp["result"]["value"] == "newmodel"
|
||||
assert seen["called"]
|
||||
@@ -993,9 +1228,9 @@ def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monke
|
||||
("/compress", "compress"),
|
||||
]:
|
||||
warning = server._mirror_slash_side_effects("sid", session, cmd)
|
||||
assert "session busy" in warning, (
|
||||
f"{cmd} should have returned busy warning, got: {warning!r}"
|
||||
)
|
||||
assert (
|
||||
"session busy" in warning
|
||||
), f"{cmd} should have returned busy warning, got: {warning!r}"
|
||||
assert f"/{expected_name}" in warning
|
||||
|
||||
# None of the mutating side-effect helpers should have fired.
|
||||
@@ -1068,7 +1303,11 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
# Stub everything _build touches
|
||||
monkeypatch.setattr(server, "_make_agent", _slow_make_agent)
|
||||
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_get_db",
|
||||
lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
@@ -1076,25 +1315,36 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
|
||||
# Shim register/unregister to observe leaks
|
||||
import tools.approval as _approval
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify",
|
||||
lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key))
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(
|
||||
_approval,
|
||||
"unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key),
|
||||
)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
# Start: session.create spawns _build thread, returns synchronously
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "session.create", "params": {"cols": 80},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.create",
|
||||
"params": {"cols": 80},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
sid = resp["result"]["session_id"]
|
||||
|
||||
# Build thread is blocked in _slow_make_agent. Close the session
|
||||
# NOW — this pops _sessions[sid] before _build can install the
|
||||
# worker/notify.
|
||||
close_resp = server.handle_request({
|
||||
"id": "2", "method": "session.close", "params": {"session_id": sid},
|
||||
})
|
||||
close_resp = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "session.close",
|
||||
"params": {"session_id": sid},
|
||||
}
|
||||
)
|
||||
assert close_resp.get("result", {}).get("closed") is True
|
||||
|
||||
# At this point session.close saw slash_worker=None (not yet
|
||||
@@ -1108,11 +1358,12 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
if closed_workers:
|
||||
break
|
||||
import time
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
assert len(closed_workers) == 1, (
|
||||
f"orphan worker was not cleaned up — closed_workers={closed_workers}"
|
||||
)
|
||||
assert (
|
||||
len(closed_workers) == 1
|
||||
), f"orphan worker was not cleaned up — closed_workers={closed_workers}"
|
||||
# Notify may be unregistered by both session.close (unconditional)
|
||||
# and the orphan-cleanup path; the key guarantee is that the build
|
||||
# thread does at least one unregister call (any prior close
|
||||
@@ -1146,21 +1397,33 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent())
|
||||
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_get_db",
|
||||
lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
|
||||
import tools.approval as _approval
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key))
|
||||
monkeypatch.setattr(
|
||||
_approval,
|
||||
"unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key),
|
||||
)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "session.create", "params": {"cols": 80},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.create",
|
||||
"params": {"cols": 80},
|
||||
}
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
|
||||
# Wait for the build to finish (ready event inside session dict).
|
||||
@@ -1169,12 +1432,12 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
|
||||
|
||||
# Build finished without a close race — nothing should have been
|
||||
# cleaned up by the orphan check.
|
||||
assert closed_workers == [], (
|
||||
f"build thread closed its own worker despite no race: {closed_workers}"
|
||||
)
|
||||
assert unregistered_keys == [], (
|
||||
f"build thread unregistered its own notify despite no race: {unregistered_keys}"
|
||||
)
|
||||
assert (
|
||||
closed_workers == []
|
||||
), f"build thread closed its own worker despite no race: {closed_workers}"
|
||||
assert (
|
||||
unregistered_keys == []
|
||||
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
|
||||
|
||||
# Session should have the live worker installed.
|
||||
assert session.get("slash_worker") is not None
|
||||
|
||||
@@ -455,6 +455,17 @@ def _write_config_key(key_path: str, value):
|
||||
_save_cfg(cfg)
|
||||
|
||||
|
||||
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
|
||||
|
||||
|
||||
def _coerce_statusbar(raw) -> str:
|
||||
if raw is False:
|
||||
return "off"
|
||||
if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES:
|
||||
return s
|
||||
return "top"
|
||||
|
||||
|
||||
def _load_reasoning_config() -> dict | None:
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
|
||||
@@ -2499,12 +2510,11 @@ def _(rid, params: dict) -> dict:
|
||||
)
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key in ("compact", "statusbar"):
|
||||
if key == "compact":
|
||||
raw = str(value or "").strip().lower()
|
||||
cfg0 = _load_cfg()
|
||||
d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {}
|
||||
def_key = "tui_compact" if key == "compact" else "tui_statusbar"
|
||||
cur_b = bool(d0.get(def_key, False if key == "compact" else True))
|
||||
cur_b = bool(d0.get("tui_compact", False))
|
||||
if raw in ("", "toggle"):
|
||||
nv_b = not cur_b
|
||||
elif raw == "on":
|
||||
@@ -2512,10 +2522,27 @@ def _(rid, params: dict) -> dict:
|
||||
elif raw == "off":
|
||||
nv_b = False
|
||||
else:
|
||||
return _err(rid, 4002, f"unknown {key} value: {value}")
|
||||
_write_config_key(f"display.{def_key}", nv_b)
|
||||
out = "on" if nv_b else "off"
|
||||
return _ok(rid, {"key": key, "value": out})
|
||||
return _err(rid, 4002, f"unknown compact value: {value}")
|
||||
_write_config_key("display.tui_compact", nv_b)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
|
||||
|
||||
if key == "statusbar":
|
||||
raw = str(value or "").strip().lower()
|
||||
display = _load_cfg().get("display")
|
||||
d0 = display if isinstance(display, dict) else {}
|
||||
current = _coerce_statusbar(d0.get("tui_statusbar", "top"))
|
||||
|
||||
if raw in ("", "toggle"):
|
||||
nv = "top" if current == "off" else "off"
|
||||
elif raw == "on":
|
||||
nv = "top"
|
||||
elif raw in _STATUSBAR_MODES:
|
||||
nv = raw
|
||||
else:
|
||||
return _err(rid, 4002, f"unknown statusbar value: {value}")
|
||||
|
||||
_write_config_key("display.tui_statusbar", nv)
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
@@ -2633,8 +2660,11 @@ def _(rid, params: dict) -> dict:
|
||||
on = bool(_load_cfg().get("display", {}).get("tui_compact", False))
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
if key == "statusbar":
|
||||
on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True))
|
||||
return _ok(rid, {"value": "on" if on else "off"})
|
||||
display = _load_cfg().get("display")
|
||||
raw = (
|
||||
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
|
||||
)
|
||||
return _ok(rid, {"value": _coerce_statusbar(raw)})
|
||||
if key == "mtime":
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'r
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import instances from '../instances.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js'
|
||||
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
@@ -51,7 +52,9 @@ export function AlternateScreen(t0: Props) {
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''))
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap'
|
||||
},
|
||||
'wrap-char': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-char'
|
||||
},
|
||||
'wrap-trim': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -343,7 +343,7 @@ function wrapWithSoftWrap(
|
||||
maxWidth: number,
|
||||
textWrap: Parameters<typeof wrapText>[2]
|
||||
): { wrapped: string; softWrap: boolean[] | undefined } {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') {
|
||||
return {
|
||||
wrapped: wrapText(plainText, maxWidth, textWrap),
|
||||
softWrap: undefined
|
||||
|
||||
@@ -55,6 +55,7 @@ export type TextStyles = {
|
||||
export type Styles = {
|
||||
readonly textWrap?:
|
||||
| 'wrap'
|
||||
| 'wrap-char'
|
||||
| 'wrap-trim'
|
||||
| 'end'
|
||||
| 'middle'
|
||||
|
||||
@@ -50,6 +50,10 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
||||
})
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-char') {
|
||||
return wrapAnsi(text, maxWidth, { trim: false, hard: true, wordWrap: false })
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-trim') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: true,
|
||||
|
||||
@@ -395,10 +395,7 @@ describe('topLevelSubagents', () => {
|
||||
})
|
||||
|
||||
it('excludes children whose parent is present', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })
|
||||
]
|
||||
const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })]
|
||||
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
|
||||
})
|
||||
|
||||
60
ui-tui/src/__tests__/textInputWrap.test.ts
Normal file
60
ui-tui/src/__tests__/textInputWrap.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cursorLayout, offsetFromPosition } from '../components/textInput.js'
|
||||
|
||||
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||
it('places cursor mid-line at its column', () => {
|
||||
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
|
||||
})
|
||||
|
||||
it('places cursor at end of a non-full line', () => {
|
||||
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
|
||||
})
|
||||
|
||||
it('wraps to next line when cursor lands exactly at the right edge', () => {
|
||||
// 8 chars on an 8-col line: text fills the row exactly; the cursor's
|
||||
// inverted-space cell overflows to col 0 of the next row.
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('tracks a word across a char-wrap boundary without jumping', () => {
|
||||
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" —
|
||||
// typing incremental letters doesn't reshuffle the word across lines.
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 })
|
||||
})
|
||||
|
||||
it('honours explicit newlines', () => {
|
||||
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('does not wrap when cursor is before the right edge', () => {
|
||||
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
|
||||
})
|
||||
|
||||
it('maps clicks within a single line', () => {
|
||||
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
|
||||
})
|
||||
|
||||
it('maps clicks past end to value length', () => {
|
||||
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('maps clicks on a wrapped second row at cols boundary', () => {
|
||||
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0
|
||||
// should land on 'i' (offset 8).
|
||||
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
|
||||
})
|
||||
|
||||
it('maps clicks past a \\n into the target line', () => {
|
||||
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay } from '../app/useConfigSync.js'
|
||||
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
@@ -36,10 +36,20 @@ describe('applyDisplay', () => {
|
||||
expect(s.inlineDiffs).toBe(false)
|
||||
expect(s.showCost).toBe(true)
|
||||
expect(s.showReasoning).toBe(true)
|
||||
expect(s.statusBar).toBe(false)
|
||||
expect(s.statusBar).toBe('off')
|
||||
expect(s.streaming).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces legacy true + "on" alias to top', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
})
|
||||
|
||||
it('applies v1 parity defaults when display fields are missing', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
@@ -50,7 +60,7 @@ describe('applyDisplay', () => {
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.showCost).toBe(false)
|
||||
expect(s.showReasoning).toBe(false)
|
||||
expect(s.statusBar).toBe(true)
|
||||
expect(s.statusBar).toBe('top')
|
||||
expect(s.streaming).toBe(true)
|
||||
})
|
||||
|
||||
@@ -64,4 +74,42 @@ describe('applyDisplay', () => {
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.streaming).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the new string statusBar modes', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('bottom')
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeStatusBar', () => {
|
||||
it('maps legacy bool + on alias to top/off', () => {
|
||||
expect(normalizeStatusBar(true)).toBe('top')
|
||||
expect(normalizeStatusBar(false)).toBe('off')
|
||||
expect(normalizeStatusBar('on')).toBe('top')
|
||||
})
|
||||
|
||||
it('passes through the canonical enum', () => {
|
||||
expect(normalizeStatusBar('off')).toBe('off')
|
||||
expect(normalizeStatusBar('top')).toBe('top')
|
||||
expect(normalizeStatusBar('bottom')).toBe('bottom')
|
||||
})
|
||||
|
||||
it('defaults missing/unknown values to top', () => {
|
||||
expect(normalizeStatusBar(undefined)).toBe('top')
|
||||
expect(normalizeStatusBar(null)).toBe('top')
|
||||
expect(normalizeStatusBar('sideways')).toBe('top')
|
||||
expect(normalizeStatusBar(42)).toBe('top')
|
||||
})
|
||||
|
||||
it('trims whitespace and folds case', () => {
|
||||
expect(normalizeStatusBar(' Bottom ')).toBe('bottom')
|
||||
expect(normalizeStatusBar('TOP')).toBe('top')
|
||||
expect(normalizeStatusBar(' on ')).toBe('top')
|
||||
expect(normalizeStatusBar('OFF')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface StateSetter<T> {
|
||||
(value: SetStateAction<T>): void
|
||||
}
|
||||
|
||||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export interface SelectionApi {
|
||||
clearSelection: () => void
|
||||
copySelection: () => string
|
||||
@@ -89,7 +91,7 @@ export interface UiState {
|
||||
showReasoning: boolean
|
||||
sid: null | string
|
||||
status: string
|
||||
statusBar: boolean
|
||||
statusBar: StatusBarMode
|
||||
streaming: boolean
|
||||
theme: Theme
|
||||
usage: Usage
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
||||
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
|
||||
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
|
||||
import type { StatusBarMode } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
@@ -305,19 +306,29 @@ export const coreCommands: SlashCommand[] = [
|
||||
|
||||
{
|
||||
aliases: ['sb'],
|
||||
help: 'toggle status bar',
|
||||
help: 'status bar position (on|off|top|bottom)',
|
||||
name: 'statusbar',
|
||||
run: (arg, ctx) => {
|
||||
const next = flagFromArg(arg, ctx.ui.statusBar)
|
||||
const mode = arg.trim().toLowerCase()
|
||||
const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off'
|
||||
|
||||
if (next === null) {
|
||||
return ctx.transcript.sys('usage: /statusbar [on|off|toggle]')
|
||||
const next: null | StatusBarMode =
|
||||
!mode || mode === 'toggle'
|
||||
? toggle
|
||||
: mode === 'on' || mode === 'top'
|
||||
? 'top'
|
||||
: mode === 'off' || mode === 'bottom'
|
||||
? mode
|
||||
: null
|
||||
|
||||
if (!next) {
|
||||
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
|
||||
}
|
||||
|
||||
patchUiState({ statusBar: next })
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
|
||||
|
||||
queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`))
|
||||
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
status: 'summoning hermes…',
|
||||
statusBar: true,
|
||||
statusBar: 'top',
|
||||
streaming: true,
|
||||
theme: DEFAULT_THEME,
|
||||
usage: ZERO
|
||||
|
||||
@@ -10,9 +10,20 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { StatusBarMode } from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
|
||||
bottom: 'bottom',
|
||||
off: 'off',
|
||||
on: 'top',
|
||||
top: 'top'
|
||||
}
|
||||
|
||||
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
|
||||
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
@@ -37,7 +48,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
showCost: !!d.show_cost,
|
||||
showReasoning: !!d.show_reasoning,
|
||||
statusBar: d.tui_statusbar !== false,
|
||||
statusBar: normalizeStatusBar(d.tui_statusbar),
|
||||
streaming: d.streaming !== false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import type {
|
||||
ApprovalRespondResponse,
|
||||
ConfigSetResponse,
|
||||
SecretRespondResponse,
|
||||
SudoRespondResponse,
|
||||
VoiceRecordResponse
|
||||
@@ -377,6 +378,29 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return cActions.openEditor()
|
||||
}
|
||||
|
||||
// shift-tab flips yolo without spending a turn (claude-code parity)
|
||||
if (key.shift && key.tab && !cState.completions.length) {
|
||||
if (!live.sid) {
|
||||
return void actions.sys('yolo needs an active session')
|
||||
}
|
||||
|
||||
// gateway.rpc swallows errors with its own sys() message and resolves to null,
|
||||
// so we only speak when it came back with a real shape. null = rpc already spoke.
|
||||
return void gateway.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
|
||||
if (r?.value === '1') {
|
||||
return actions.sys('yolo on')
|
||||
}
|
||||
|
||||
if (r?.value === '0') {
|
||||
return actions.sys('yolo off')
|
||||
}
|
||||
|
||||
if (r) {
|
||||
actions.sys('failed to toggle yolo')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (key.tab && cState.completions.length) {
|
||||
const row = cState.completions[cState.compIdx]
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtCwdBranch } from '../domain/paths.js'
|
||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
ClarifyRespondResponse,
|
||||
@@ -314,12 +314,14 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
||||
|
||||
// ── Terminal tab title ─────────────────────────────────────────────
|
||||
// Show model name + status so users can identify the Hermes tab.
|
||||
const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
||||
const titleStatus = ui.busy ? '⏳' : '✓'
|
||||
const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'
|
||||
useTerminalTitle(terminalTitle)
|
||||
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
|
||||
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
||||
|
||||
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
|
||||
|
||||
const tabCwd = ui.info?.cwd
|
||||
|
||||
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid || !stdout) {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
const GOLD = '\x1b[38;2;255;215;0m'
|
||||
const AMBER = '\x1b[38;2;255;191;0m'
|
||||
const BRONZE = '\x1b[38;2;205;127;50m'
|
||||
const DIM = '\x1b[38;2;184;134;11m'
|
||||
const RESET = '\x1b[0m'
|
||||
|
||||
const LOGO = [
|
||||
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
|
||||
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
|
||||
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
|
||||
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
|
||||
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
|
||||
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ '
|
||||
]
|
||||
|
||||
const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const
|
||||
const LOGO_WIDTH = 98
|
||||
|
||||
const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}`
|
||||
const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}`
|
||||
|
||||
export function bootBanner(cols: number = process.stdout.columns || 80): string {
|
||||
const body = cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK
|
||||
|
||||
return `\n${body}\n${TAGLINE}\n\n`
|
||||
}
|
||||
@@ -156,7 +156,11 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||
return () => clearTimeout(id)
|
||||
}, [t.color.amber, tick])
|
||||
|
||||
return <Text color={color}>{active ? '♥' : ' '}</Text>
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Text color={color}>♥</Text>
|
||||
}
|
||||
|
||||
export function StatusRule({
|
||||
@@ -187,7 +191,7 @@ export function StatusRule({
|
||||
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box height={1}>
|
||||
<Box flexShrink={1} width={leftWidth}>
|
||||
<Text color={t.color.bronze} wrap="truncate-end">
|
||||
{'─ '}
|
||||
|
||||
@@ -183,37 +183,19 @@ const ComposerPane = memo(function ComposerPane({
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
turnStartedAt={status.turnStartedAt}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatingOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
/>
|
||||
</Box>
|
||||
<StatusRulePane at="top" composer={composer} status={status} />
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
|
||||
<FloatingOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
/>
|
||||
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
@@ -236,8 +218,9 @@ const ComposerPane = memo(function ComposerPane({
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} position="relative">
|
||||
{/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */}
|
||||
<TextInput
|
||||
columns={Math.max(20, composer.cols - pw)}
|
||||
columns={Math.max(20, composer.cols - pw - 2)}
|
||||
onChange={composer.updateInput}
|
||||
onPaste={composer.handleTextPaste}
|
||||
onSubmit={composer.submit}
|
||||
@@ -273,6 +256,38 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
||||
)
|
||||
})
|
||||
|
||||
const StatusRulePane = memo(function StatusRulePane({
|
||||
at,
|
||||
composer,
|
||||
status
|
||||
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
|
||||
const ui = useStore($uiState)
|
||||
|
||||
if (ui.statusBar !== at) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={at === 'top' ? 1 : 0}>
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
busy={ui.busy}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
turnStartedAt={status.turnStartedAt}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
export const AppLayout = memo(function AppLayout({
|
||||
actions,
|
||||
composer,
|
||||
@@ -295,16 +310,20 @@ export const AppLayout = memo(function AppLayout({
|
||||
</Box>
|
||||
|
||||
{!overlay.agents && (
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
/>
|
||||
)}
|
||||
<>
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
/>
|
||||
|
||||
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||
|
||||
<StatusRulePane at="bottom" composer={composer} status={status} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
)
|
||||
|
||||
@@ -167,9 +167,11 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
|
||||
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
|
||||
}
|
||||
|
||||
function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared
|
||||
// cursor lines up with what <Text wrap="wrap-char"> actually renders
|
||||
export function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
const w = Math.max(1, cols - 1)
|
||||
const w = Math.max(1, cols)
|
||||
|
||||
let col = 0,
|
||||
line = 0
|
||||
@@ -200,17 +202,23 @@ function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
col += sw
|
||||
}
|
||||
|
||||
// trailing cursor-cell overflows to the next row at the wrap column
|
||||
if (col >= w) {
|
||||
line++
|
||||
col = 0
|
||||
}
|
||||
|
||||
return { column: col, line }
|
||||
}
|
||||
|
||||
function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
if (!value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const targetRow = Math.max(0, Math.floor(row))
|
||||
const targetCol = Math.max(0, Math.floor(col))
|
||||
const w = Math.max(1, cols - 1)
|
||||
const w = Math.max(1, cols)
|
||||
|
||||
let line = 0
|
||||
let column = 0
|
||||
@@ -802,7 +810,7 @@ export function TextInput({
|
||||
}}
|
||||
ref={boxRef}
|
||||
>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
<Text wrap="wrap-char">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,6 @@ export const HOTKEYS: [string, string][] = [
|
||||
['Home/End', 'start / end of line'],
|
||||
['Shift+Enter / Alt+Enter', 'insert newline'],
|
||||
['\\+Enter', 'multi-line continuation (fallback)'],
|
||||
['!cmd', 'run shell command'],
|
||||
['{!cmd}', 'interpolate shell output inline']
|
||||
['!<cmd>', 'run a shell command (e.g. !ls, !git status)'],
|
||||
['{!<cmd>}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")']
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
|
||||
import { bootBanner } from './bootBanner.js'
|
||||
import { GatewayClient } from './gatewayClient.js'
|
||||
import { setupGracefulExit } from './lib/gracefulExit.js'
|
||||
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
|
||||
@@ -10,8 +9,6 @@ if (!process.stdin.isTTY) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.stdout.write(bootBanner())
|
||||
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface ConfigDisplayConfig {
|
||||
streaming?: boolean
|
||||
thinking_mode?: string
|
||||
tui_compact?: boolean
|
||||
tui_statusbar?: boolean
|
||||
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||
}
|
||||
|
||||
export interface ConfigFullResponse {
|
||||
|
||||
Reference in New Issue
Block a user