fix(cli): glyph layout keeps classic parity for the status line, spinner and reasoning

The glyph layout had dropped things the classic CLI shows:

- the status line rendered a private default subset: context detail, cache
  hit, latency, tokens/s, duration and prompt/idle timers were gone. It now
  renders the classic segments unchanged (same fields, tiers and
  display.status_bar.fields filter) plus cwd and key hints when they fit.
- the composer rule stripped the kaomoji/tool emoji and lost the streamed
  token count (the classic spinner suffix). Both are back.
- reasoning no longer streamed: it was buffered and collapsed to a single
  summary line. It streams live again as in classic, under the dim bar,
  closed by the summary line; post-hoc reasoning keeps classic first-10-lines.
- unset busy_input_mode silently switched to steer in this layout. It is
  interrupt in both layouts again (Tab still queues).
This commit is contained in:
Teknium committed 2026-10-19 07:12:58 -07:00
1 parent e609cbc58a
commit 2aef21193c
5 files changed
+93 -54

No files matched your search

+1 -1
View File
@@ -208,7 +208,7 @@ def _cli_config_defaults():
# /resume recap tuning and show_reasoning: keep in sync with hermes_cli/config.py DEFAULT_CONFIG
"resume_display": "full", "resume_exchanges": 10, "resume_max_user_chars": 300,
"resume_max_assistant_chars": 200, "resume_max_assistant_lines": 3, "resume_skip_tool_only": True,
"show_reasoning": True, "reasoning_full": False, "streaming": True, "busy_input_mode": None, # None: per display.cli_layout (steer / interrupt)
"show_reasoning": True, "reasoning_full": False, "streaming": True, "busy_input_mode": "interrupt",
"cli_layout": "modern",
"persistent_output": True, "persistent_output_max_lines": 200,
# Also clear scrollback on redraw/resize recovery; off because users prefer history.
+32 -31
View File
@@ -34,9 +34,8 @@ def layout_from_config(display: dict) -> bool:
def busy_input_mode_from_config(display: dict) -> str:
"""``display.busy_input_mode`` when set; unset steers in this layout and interrupts in classic."""
mode = (display or {}).get("busy_input_mode") or ("steer" if layout_from_config(display) else "interrupt")
return str(mode).strip().lower()
"""``display.busy_input_mode``; unset interrupts in both layouts (Tab still queues in this one)."""
return str((display or {}).get("busy_input_mode") or "interrupt").strip().lower()
def _skin_hex(key: str, fallback: str) -> str:
@@ -100,21 +99,30 @@ def thought_title(text: str) -> str:
def thought_line(seconds: float | None, text: str) -> str:
"""``∴ Thought for 3s · Planning the change`` (dim italic), the collapsed reasoning row."""
"""``∴ Thought for 3s · Planning the change`` (dim italic), closing a reasoning block."""
title = thought_title(text)
took = t("cli.modern.thought_for", seconds=f"{max(seconds or 0.0, 0.0):.0f}") if seconds else t("cli.modern.thought")
body = f"{took} · {title}" if title else took
return f"\x1b[2;3m∴ {body}{_RST}"
def thought_body(text: str) -> list[str]:
"""Full reasoning under a ``│`` gutter, for ``/reasoning full``."""
return [f"\x1b[2;3m│ {line}{_RST}" if line.strip() else "\x1b[2;3m│\x1b[0m" for line in text.strip().splitlines()]
_REASONING_PREVIEW_LINES = 10 # classic's post-hoc reasoning box shows this many unless /reasoning full
def reasoning_text_line(line: str) -> str:
"""One streamed reasoning line: dim, no frame (the dim bar above opens the block)."""
return f"{_DIM}{line}{_RST}"
def thought_rows(seconds: float | None, text: str, full: bool) -> list[str]:
"""A reasoning block's scrollback rows: dim bar, collapsed line, and the body under ``full``."""
return [separator(False), thought_line(seconds, text), *(thought_body(text) if full else [])]
"""A reasoning block printed after the fact (no live stream): dim bar, its text (first 10
lines unless ``full``, as classic), then the ``∴ Thought for 3s`` summary."""
lines = (text or "").strip().splitlines()
shown = lines if full or len(lines) <= _REASONING_PREVIEW_LINES else lines[:_REASONING_PREVIEW_LINES]
rows = [separator(False), *(reasoning_text_line(line) for line in shown)]
if len(shown) < len(lines):
rows.append(f"{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - len(shown))}{_RST}")
return [*rows, thought_line(seconds, text)]
def completed_tool_line(cli, function_name: str, args: dict | None, duration: float, result=None,
@@ -206,19 +214,11 @@ def _cwd_label() -> str:
def activity_text(cli) -> str:
"""What the agent is doing, without the kaomoji face or per-tool emoji."""
"""What the agent is doing: the classic spinner's text (kaomoji face or tool emoji included)."""
if getattr(cli, "_command_running", False):
return str(getattr(cli, "_command_status", "") or "")
text = str(getattr(cli, "_spinner_text", "") or "").strip()
if not text:
return t("cli.modern.working") if getattr(cli, "_agent_running", False) else ""
if text.endswith("...") and " " in text: # "(⊙_⊙) pondering..." → "Pondering…"
verb = text.rsplit(" ", 1)[-1].rstrip(".")
return f"{verb[:1].upper()}{verb[1:]}…"
head, _, rest = text.partition(" ")
if rest and head and not any(ch.isalnum() for ch in head): # drop the emoji token
return rest
return text
return text or (t("cli.modern.working") if getattr(cli, "_agent_running", False) else "")
def _turn_elapsed(cli) -> str:
@@ -256,9 +256,10 @@ def top_rule_fragments(cli) -> list:
if busy:
frame = _SPIN[int(time.monotonic() * 10) % len(_SPIN)]
left = [(rule, "── "), (accent, frame + " "), ("", activity_text(cli))]
elapsed = _turn_elapsed(cli)
if elapsed:
left.append(("class:status-bar-dim bg:default", f" · {elapsed}"))
# Classic's spinner suffix: turn time and the streamed-token count (``↓ 547 tok``).
for extra in (_turn_elapsed(cli), cli._spinner_token_flow()):
if extra:
left.append(("class:status-bar-dim bg:default", f" · {extra}"))
left.append((rule, " "))
title = cli._get_status_bar_session_title()
right = [(rule, " "), (f"fg:{_skin_hex('ui_label', '#DAA520')}", title), (rule, " ─")] if title else []
@@ -298,27 +299,27 @@ def _render_safely(render, cli) -> list:
return [("", f" {getattr(cli, 'model', '') or 'Hermes'}")]
# Live-state badges the classic bar shows, minus its throughput/clock fields: what changes per turn.
_DEFAULT_FIELDS = frozenset({"model", "context_pct", "compressions", "bg_tasks", "bg_processes", "bg_subagents",
"goal", "focus", "yolo", "stash", "battery"})
def _status_line_fragments(cli) -> list:
"""The classic bar's segments (same fields, same width tiers, same ``display.status_bar.fields``
filter), then cwd (branch) and the live key hints when there is room."""
width = max(10, cli._get_tui_terminal_width())
dim, strong = "class:status-bar-dim", "class:status-bar-strong"
snapshot = cli._get_status_bar_snapshot()
field_set = cli._get_status_bar_field_set()
fields = _DEFAULT_FIELDS if field_set is None else field_set
def _on(name):
return field_set is None or name in field_set
left: list = []
for seg in cli._status_bar_segments(snapshot, width, fields, cli._is_session_yolo_active(), styled=True):
for seg in cli._status_bar_segments(snapshot, width, field_set, cli._is_session_yolo_active(), styled=True):
if left:
left.append((dim, " · "))
left.extend(seg)
stash = cli._prompt_stash.indicator() if "stash" in fields and hasattr(cli, "_prompt_stash") else ""
stash = cli._prompt_stash.indicator() if _on("stash") and hasattr(cli, "_prompt_stash") else ""
if stash: # a parked draft is never silently forgotten
left += [(dim, " · "), (strong, stash)]
battery = snapshot.get("battery_label") or "" # only set while /battery is on
if battery and "battery" in fields:
if battery and _on("battery"):
left[0:0] = [("", " "), (cli._battery_status_style(snapshot.get("battery_category", "dim")), battery),
(dim, " ·")]
vim = cli._vim_mode_label()
+18 -6
View File
@@ -266,9 +266,20 @@ class CLIStreamMixin:
if getattr(self, "_stream_box_opened", False):
return
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import reasoning_text_line, separator
if not getattr(self, "_reasoning_box_opened", False):
self._reasoning_box_opened, self._reasoning_started_at, self._reasoning_buf = True, time.monotonic(), ""
self._reasoning_box_opened, self._reasoning_started_at = True, time.monotonic()
self._reasoning_buf, self._reasoning_full_text = "", ""
_cprint("\n" + separator(False))
self._reasoning_full_text = getattr(self, "_reasoning_full_text", "") + text
self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text
# Live, as classic: complete lines now, long partial lines force-flushed.
while "\n" in self._reasoning_buf:
line, self._reasoning_buf = self._reasoning_buf.split("\n", 1)
_cprint(reasoning_text_line(line))
if len(self._reasoning_buf) > 80:
_cprint(reasoning_text_line(self._reasoning_buf))
self._reasoning_buf = ""
return
if not getattr(self, "_reasoning_box_opened", False):
self._reasoning_box_opened = True
@@ -312,12 +323,13 @@ class CLIStreamMixin:
if not getattr(self, "_reasoning_box_opened", False):
return
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import thought_rows
text = getattr(self, "_reasoning_buf", "")
from hermes_cli.cli_modern_layout import reasoning_text_line, thought_line
if getattr(self, "_reasoning_buf", ""):
_cprint(reasoning_text_line(self._reasoning_buf))
started = getattr(self, "_reasoning_started_at", 0.0)
for line in thought_rows(time.monotonic() - started if started else None, text, self.reasoning_full):
_cprint(line)
self._reasoning_buf, self._reasoning_box_opened = "", False
_cprint(thought_line(time.monotonic() - started if started else None,
getattr(self, "_reasoning_full_text", "")))
self._reasoning_buf, self._reasoning_full_text, self._reasoning_box_opened = "", "", False
if not getattr(self, "_stream_box_live", False):
self._release_held_status_lines()
return
+33 -7
View File
@@ -36,14 +36,16 @@ def test_glyph_layout_scrollback_is_width_independent(monkeypatch, modern):
assert boxed and narrow != wide
return
assert narrow == wide and not boxed, wide
thought = [line for line in wide if line.startswith("∴")]
assert len(thought) == 1 and "Planning the change" in thought[0], wide
assert "I will read calc.py first." not in "\n".join(wide) # collapsed unless /reasoning full
assert any("Added multiply to calc.py." in line for line in wide)
# Reasoning and the answer each open under a fixed-width bar (so it never re-wraps on resize).
rows = "\n".join(wide).split("\n")
assert rows[rows.index(thought[0]) - 1] == "─" * 40, rows
thought = [line for line in rows if line.startswith("∴")]
assert len(thought) == 1 and "Planning the change" in thought[0], rows
# Reasoning streams in full like classic, under a fixed-width dim bar (never re-wraps on resize)
# and closed by the ∴ summary; the answer opens under its own bar.
start = rows.index("**Planning the change**")
assert rows[start - 1] == "─" * 40 and rows[start + 1] == "I will read calc.py first.", rows
assert rows[start + 2] == thought[0], rows
assert rows[next(i for i, r in enumerate(rows) if r.startswith("☤ Hermes")) - 1] == "─" * 40, rows
assert any("Added multiply to calc.py." in line for line in rows)
@pytest.mark.parametrize("modern", [True, False])
@@ -72,7 +74,7 @@ def test_panel_rows_never_carry_a_newline():
@pytest.mark.parametrize("display, modern, busy", [
({}, True, "steer"),
({}, True, "interrupt"),
({"cli_layout": "classic"}, False, "interrupt"),
({"busy_input_mode": "queue"}, True, "queue"),
({"cli_layout": "classic", "busy_input_mode": "steer"}, False, "steer"),
@@ -90,3 +92,27 @@ def test_layout_and_busy_default_through_the_config_loader(tmp_path, monkeypatch
loaded = cli.load_cli_config()["display"]
assert layout_from_config(loaded) is modern
assert busy_input_mode_from_config(loaded) == busy
def test_status_line_keeps_every_classic_status_bar_field(monkeypatch):
"""The glyph footer renders the classic bar's segments: nothing (tps, cache, latency, context
detail, duration...) is dropped by a layout-private default subset."""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli._modern_layout, cli._status_bar_visible = True, True
snapshot = {"model_short": "m", "duration": "6h 43m", "context_percent": 7, "context_length": 1_000_000,
"context_tokens": 73_200, "context_estimated": True, "avg_latency_label": "9.0s",
"avg_velocity_label": "61 t/s", "cache_hit_rate": 28.9, "cache_read_tokens": 1, "prompt_tokens": 1,
"active_background_subagents": 3, "prompt_elapsed": "⏱ 10s"}
monkeypatch.setattr(HermesCLI, "_get_status_bar_snapshot", lambda self: snapshot)
monkeypatch.setattr(HermesCLI, "_get_tui_terminal_width", lambda self: 400)
monkeypatch.setattr(HermesCLI, "_get_status_bar_field_set", lambda self: None)
monkeypatch.setattr(HermesCLI, "_is_session_yolo_active", lambda self: False)
monkeypatch.setattr(HermesCLI, "_vim_mode_label", lambda self: "")
monkeypatch.setattr(HermesCLI, "_cache_hit_rate", lambda self, snap, precision=1: (28.9, "◎ 28.9%"))
monkeypatch.setattr("hermes_cli.cli_modern_layout.hint_pairs", lambda cli: [])
monkeypatch.setattr("hermes_cli.cli_modern_layout._cwd_label", lambda: "~")
line = "".join(text for _, text in cli._get_status_bar_fragments())
for piece in ("73.2K/1M", "7%", "◎ 28.9%", "◷ 9.0s", "↑ 61 t/s", "⛓ 3", "6h 43m", "⏱ 10s"):
assert piece in line, (piece, line)
+9 -9
View File
@@ -191,14 +191,14 @@ acknowledge it before continuing. Do not resend the queued input.
Added multiply; pytest isn't installed here, so I ran the tests directly: 2 passed.
─ 15.0s · edited 1 file +4 -0 · read 2 files · 8:27 AM
── ⠹ Running tests · 14s ─────────────────────────── Add multiply to calc.py ─
❯ type to steer this turn · Tab queues it for after
❯ type to redirect this turn · Tab queues it for after
──────────────────────────────────────────────────────────────────────────────
gpt-5-mini · ctx [░░░░░░░░] 4% · ~/project (main) enter steer · tab queue · ctrl+c stop
gpt-5-mini · ctx [░░░░░░░░] 4% · ~/project (main) enter interrupt · tab queue · ctrl+c stop
```
- `❯` your message, `∴` a reasoning step collapsed to its title and duration (`/reasoning full` prints the text under a `│` gutter), `●` a finished tool call (`✗` in red when it failed, with its exit code), `│` an edit's diff under its row, `☤` the answer, and `─` the turn's summary line.
- The composer's top rule carries the live activity (spinner, what's running, elapsed time) on the left and the session title on the right; no spinner row is printed into the scrollback.
- The status line sits under the composer: model, context gauge, and working directory (with git branch) on the left; the keys that do something right now on the right.
- A 40-column bar opens each user message and answer (accent) and each reasoning block (dim); reasoning streams live as in classic and ends with a `∴ Thought for 3s · <title>` line. `❯` your message, `●` a finished tool call (`✗` in red when it failed, with its exit code), `│` an edit's diff under its row, `☤` the answer, and `─` the turn's summary line.
- The composer's top rule carries the live activity (spinner, what's running, elapsed time, streamed tokens) on the left and the session title on the right; no spinner row is printed into the scrollback.
- The status line sits under the composer: the same fields as the classic status bar (model, context, cache hit, latency, tokens/s, background work, goal, duration, YOLO…) plus the working directory (with git branch) on the left; the keys that do something right now on the right.
- The welcome banner is the same in both layouts.
Set `display.cli_layout: classic` to keep the boxed reasoning and answer panels and the status bar above the input.
@@ -265,7 +265,7 @@ On macOS, `F6`/`F7` mean the physical function keys, not the media/system contro
| `Ctrl+R` / `F7` | Toggle the live work dock between its multi-row preview and a single summary line without moving composer focus. `Ctrl+R` is the reliable fallback when macOS reserves the function-key row. Besides subagents and background processes, the dock shows a standing `/goal` (active, parked or paused, with turns used) on its top row and the prompts waiting in `/queue` on its bottom rows. |
| `Ctrl+D` | Exit (on an empty prompt; with text it deletes under the cursor). While the gateway runs a turn, it exits and leaves the turn running: the gateway finishes it, ends the session afterwards, and `hermes --resume` shows the reply. A turn that asks a question (approval or clarify) meanwhile waits for its timeout or for you to resume. Use `Ctrl+C` to stop the turn instead. |
| `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. |
| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands. While the agent is working in the `modern` layout, `Tab` on a typed message queues it for the next turn instead of steering |
| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands. While the agent is working in the `modern` layout, `Tab` on a typed message queues it for the next turn |
| `!<command>` | **Shell mode** — run a shell command yourself without spending a model turn (e.g. `!git status`, `!pytest -x`). See below. |
**Multiline paste preview.** When you paste a multi-line block, the CLI echoes a compact single-line preview (`[pasted: 47 lines, 1,842 chars — press Enter to send]`) instead of dumping the whole payload into the scrollback. The full content is still what gets sent; this is just display polish.
@@ -460,14 +460,14 @@ The `display.busy_input_mode` config key controls what happens when you press En
| Mode | Behavior |
|------|----------|
| `"interrupt"` (default for `display.cli_layout: classic`) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved. A running foreground terminal command is moved to the background (not killed — you get a completion notification) so your message is read immediately; other running tools finish first |
| `"interrupt"` (default) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved. A running foreground terminal command is moved to the background (not killed — you get a completion notification) so your message is read immediately; other running tools finish first |
| `"queue"` | Your message is queued as the next turn after the agent finishes. The queue lives on the gateway (crash-durable, visible to every attached client). |
| `"steer"` (default for `display.cli_layout: modern`) | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn. `Tab` queues instead |
| `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn |
```yaml
# ~/.hermes/config.yaml
display:
busy_input_mode: "steer" # or "queue" or "interrupt"; unset follows display.cli_layout
busy_input_mode: "steer" # or "queue" or "interrupt" (default)
```
`"queue"` mode prepares a separate follow-up turn. `"steer"` always waits for the next tool-result boundary. The default `"interrupt"` mode responds sooner during model generation while avoiding cancellation of a running tool; a long foreground `terminal` command (a build, a poller) is handed to the background so the agent sees your message right away instead of after the command exits. Use `/stop` when you want to cancel the turn and its foreground work. Unknown values fall back to `"interrupt"`.