feat(cli): show session titles in status bars

This commit is contained in:
Teknium
2026-08-08 12:01:54 -07:00
parent 05330e804a
commit 5a16635f40
12 changed files with 160 additions and 7 deletions
+90 -3
View File
@@ -5271,6 +5271,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"model_name": model_name,
"model_short": model_short,
"duration": format_duration_compact(elapsed_seconds),
"session_title": self._get_status_bar_session_title(),
"prompt_elapsed": self._format_prompt_elapsed(
getattr(self, "_prompt_start_time", None),
getattr(self, "_prompt_duration", 0.0),
@@ -5407,6 +5408,34 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return snapshot
def _get_status_bar_session_title(self) -> str:
"""Return the current title without polling state.db on every repaint."""
pending = str(getattr(self, "_pending_title", None) or "").strip()
session_id = str(getattr(self, "session_id", "") or "")
if pending:
self._status_bar_title_session_id = session_id
self._status_bar_title_cache = pending
self._status_bar_title_checked_at = time.monotonic()
return pending
now = time.monotonic()
cached_session_id = getattr(self, "_status_bar_title_session_id", None)
checked_at = float(getattr(self, "_status_bar_title_checked_at", 0.0) or 0.0)
if cached_session_id == session_id and now - checked_at < 1.5:
return str(getattr(self, "_status_bar_title_cache", "") or "")
title = ""
db = getattr(self, "_session_db", None)
if db is not None and session_id:
try:
title = str(db.get_session_title(session_id) or "").strip()
except Exception:
title = ""
self._status_bar_title_session_id = session_id
self._status_bar_title_cache = title
self._status_bar_title_checked_at = now
return title
@staticmethod
def _status_bar_display_width(text: str) -> int:
"""Return terminal cell width for status-bar text.
@@ -5450,6 +5479,57 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
width += ch_width
return "".join(out).rstrip() + ellipsis
@classmethod
def _right_align_status_title(cls, text: str, title: str, width: int) -> str:
"""Pin a bounded session-title badge to the far-right status-bar edge."""
title = str(title or "").strip()
if not title or width < 24:
return cls._trim_status_bar_text(text, width)
title_width = max(6, min(30, width // 3))
badge = f" {cls._trim_status_bar_text(title, title_width - 2)} "
suffix = f" ─{badge}"
left_width = max(0, width - cls._status_bar_display_width(suffix))
left = cls._trim_status_bar_text(text.rstrip(), left_width)
padding = " " * max(0, left_width - cls._status_bar_display_width(left))
return f"{left}{padding}{suffix}"
@classmethod
def _right_align_status_title_fragments(cls, frags, title: str, width: int):
"""Styled counterpart to :meth:`_right_align_status_title`."""
title = str(title or "").strip()
if not title or width < 24:
return frags
title_width = max(6, min(30, width // 3))
badge = f" {cls._trim_status_bar_text(title, title_width - 2)} "
suffix_width = cls._status_bar_display_width(" ─") + cls._status_bar_display_width(badge)
left_width = max(0, width - suffix_width)
trimmed = []
used = 0
for style, value in frags:
remaining = left_width - used
if remaining <= 0:
break
value_width = cls._status_bar_display_width(value)
if value_width <= remaining:
trimmed.append((style, value))
used += value_width
continue
clipped = cls._trim_status_bar_text(value, remaining)
if clipped:
trimmed.append((style, clipped))
used += cls._status_bar_display_width(clipped)
break
if used < left_width:
trimmed.append(("class:status-bar-dim", " " * (left_width - used)))
trimmed.extend([
("class:status-bar-dim", " ─"),
("class:status-bar-session-title", badge),
])
return trimmed
@staticmethod
def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int:
"""Return the live prompt_toolkit width, falling back to ``shutil``.
@@ -5934,6 +6014,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
battery_label = snapshot.get("battery_label") or ""
battery_prefix = f"{battery_label} │ " if battery_label else ""
focus_label = snapshot.get("focus_label") or ""
session_title = snapshot.get("session_title") or ""
yolo_active = self._is_session_yolo_active()
goal_segment = self._status_bar_goal_segment(snapshot)
@@ -5945,7 +6026,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
text += f" · {focus_label}"
if yolo_active:
text += " · ⚠ YOLO"
return self._trim_status_bar_text(text, width)
return self._right_align_status_title(text, session_title, width)
if width < 76:
parts = [f"⚕ {snapshot['model_short']}", percent_label]
if battery_label:
@@ -5969,7 +6050,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
parts.append(focus_label)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text(" · ".join(parts), width)
return self._right_align_status_title(" · ".join(parts), session_title, width)
if snapshot["context_length"]:
ctx_total = _format_context_length(snapshot["context_length"])
@@ -6006,7 +6087,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
parts.append(focus_label)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text(" │ ".join(parts), width)
return self._right_align_status_title(" │ ".join(parts), session_title, width)
except Exception:
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
@@ -6027,6 +6108,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
battery_label = snapshot.get("battery_label") or ""
battery_style = self._battery_status_style(snapshot.get("battery_category", "dim"))
focus_label = snapshot.get("focus_label") or ""
session_title = snapshot.get("session_title") or ""
if width < 52:
frags = [
@@ -6177,6 +6259,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
("class:status-bar-dim", " │"),
]
frags = self._right_align_status_title_fragments(frags, session_title, width)
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
if total_width > width:
plain_text = "".join(text for _, text in frags)
@@ -8297,6 +8381,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
try:
self._session_db.set_session_title(self.session_id, sanitized)
self._pending_title = None
self._status_bar_title_checked_at = 0.0
title = sanitized
except ValueError as e:
_cprint(f" {e} — session started untitled.")
@@ -10015,6 +10100,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Session exists in DB — set title directly
try:
if self._session_db.set_session_title(self.session_id, new_title):
self._status_bar_title_checked_at = 0.0
_cprint(f" Session title set: {new_title}")
else:
_cprint(" Session not found in database.")
@@ -17224,6 +17310,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
'status-bar-bad': 'bg:#1a1a2e #FF8C00 bold',
'status-bar-critical': 'bg:#1a1a2e #FF6B6B bold',
'status-bar-yolo': 'bg:#1a1a2e #FF4444 bold',
'status-bar-session-title': 'bg:#FFD700 #1a1a2e bold',
# Bronze horizontal rules around the input area
'input-rule': '#CD7F32',
# Clipboard image attachment badges
+33
View File
@@ -54,6 +54,26 @@ def _attach_agent(
class TestCLIStatusBar:
def test_session_title_is_right_aligned_after_it_is_queued(self):
cli_obj = _make_cli()
cli_obj._pending_title = "weekly-digest"
text = cli_obj._build_status_bar_text(width=80)
assert text.endswith(" weekly-digest ")
assert cli_obj._status_bar_display_width(text) == 80
def test_snapshot_refreshes_persisted_session_title(self):
cli_obj = _make_cli()
cli_obj.session_id = "session-1"
cli_obj._session_db = SimpleNamespace( # type: ignore[assignment]
get_session_title=lambda sid: "user-profiles" if sid == "session-1" else None
)
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["session_title"] == "user-profiles"
def test_context_style_thresholds(self):
cli_obj = _make_cli()
@@ -262,6 +282,19 @@ class TestStatusBarWidthSource:
f"({total_text!r})"
)
def test_fragments_put_session_title_at_far_right(self):
cli_obj = self._make_wide_cli()
cli_obj._pending_title = "weekly-digest"
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=100)
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
frags = cli_obj._get_status_bar_fragments()
text = "".join(value for _, value in frags)
assert text.endswith(" weekly-digest ")
assert cli_obj._status_bar_display_width(text) == 100
def test_fragments_use_pt_width_over_shutil(self):
"""When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
from unittest.mock import MagicMock, patch
@@ -165,6 +165,7 @@ const layoutProps: AppLayoutProps = {
goodVibesTick: 0,
lastTurnEndedAt: T0 - 5_000,
sessionStartedAt: T0 - 60_000,
sessionTitle: '',
showStickyPrompt: false,
statusColor: DEFAULT_THEME.color.ok,
stickyPrompt: '',
@@ -104,6 +104,22 @@ const baseProps = {
voiceLabel: ''
}
describe('StatusRule session title', () => {
it('pins the named session at the far-right edge instead of the cwd label', () => {
const element = StatusRule({
...baseProps,
sessionTitle: 'weekly-digest'
})
const rendered = textContent(element)
const title = findElementWithText(element, 'weekly-digest')
expect(rendered).toContain('weekly-digest')
expect(rendered).not.toContain('~/repo')
expect(title?.props.backgroundColor).toBe(DEFAULT_THEME.color.accent)
})
})
describe('StatusRule background-subagent indicator', () => {
it('renders ⛓ N on a wide terminal when subagents are running', () => {
const element = StatusRule({
+1
View File
@@ -585,6 +585,7 @@ export interface AppLayoutStatusProps {
goodVibesTick: number
lastTurnEndedAt: null | number
sessionStartedAt: null | number
sessionTitle: string
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
+1
View File
@@ -274,6 +274,7 @@ export const coreCommands: SlashCommand[] = [
ctx.guarded<SessionTitleResponse>(r => {
const next = (r?.title ?? title).trim()
const suffix = r?.pending ? ' (queued while session initializes)' : ''
patchUiState({ sessionTitle: next })
ctx.transcript.sys(`session title set: ${next}${suffix}`)
})
)
+2 -1
View File
@@ -570,7 +570,7 @@ export function useMainApp(gw: GatewayClient) {
useEffect(() => {
if (!ui.sid) {
patchUiState({ liveSessionCount: 0 })
patchUiState({ liveSessionCount: 0, sessionTitle: '' })
return
}
@@ -1171,6 +1171,7 @@ export function useMainApp(gw: GatewayClient) {
goodVibesTick,
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
sessionStartedAt: ui.sid ? sessionStartedAt : null,
sessionTitle: ui.sid ? ui.sessionTitle : '',
showStickyPrompt: !!stickyPrompt,
statusColor: statusColorOf(ui.status, ui.theme.color),
stickyPrompt,
+1
View File
@@ -276,6 +276,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const nextTitle = (result.title ?? requestedTitle).trim()
const suffix = result.pending ? ' (queued while session initializes)' : ''
patchUiState({ sessionTitle: nextTitle })
sys(`session title set: ${nextTitle}${suffix}`)
})
.catch((err: unknown) => {
+11 -3
View File
@@ -480,6 +480,7 @@ export function StatusRule({
bgCount,
lastTurnEndedAt,
liveSessionCount,
sessionTitle,
sessionStartedAt,
turnStartedAt,
voiceLabel,
@@ -540,7 +541,8 @@ export function StatusRule({
stringWidth(modelText) +
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel, essentialWidth)
const rightLabel = sessionTitle ? ` ${sessionTitle} ` : cwdLabel
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, rightLabel, essentialWidth)
// Whole-segment progressive disclosure for the tail: a segment renders only
// if it fits in the space left after the pinned essentials, evaluated in
@@ -748,8 +750,13 @@ export function StatusRule({
<>
<Text color={t.color.border}>{separatorWidth >= 3 ? ' ─ ' : ' '}</Text>
<Box flexShrink={0} width={rightWidth}>
<Text color={t.color.label} wrap="truncate-end">
{cwdLabel}
<Text
backgroundColor={sessionTitle ? t.color.accent : undefined}
bold={!!sessionTitle}
color={sessionTitle ? t.color.statusFg : t.color.label}
wrap="truncate-end"
>
{rightLabel}
</Text>
</Box>
</>
@@ -868,6 +875,7 @@ interface StatusRuleProps {
indicatorStyle?: IndicatorStyle
notice?: Notice | null
sessionStartedAt?: null | number
sessionTitle?: string
status: string
statusColor: string
t: Theme
+1
View File
@@ -502,6 +502,7 @@ const StatusRulePane = memo(function StatusRulePane({
notice={ui.notice}
onSessionCountClick={() => patchOverlayState({ sessions: true })}
sessionStartedAt={status.sessionStartedAt}
sessionTitle={status.sessionTitle}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
+1
View File
@@ -96,6 +96,7 @@ A persistent status bar sits above the input area, updating in real time:
| 🗜️ N | **Context compression count** — how many times the running session has been auto-compressed. Appears once the first compression fires. |
| ▶ N | **Active background tasks** — how many `/background` prompts are still running in the current session. Appears whenever at least one task is in flight. |
| Duration | Elapsed session time |
| Session title | Once the session has a title, it appears as a gold badge pinned to the far-right edge. Long titles truncate before displacing the essential model and context fields. |
| ⚠ YOLO | **YOLO mode warning** — shown whenever `HERMES_YOLO_MODE` is on (either `hermes --yolo` at launch or `/yolo` toggled mid-session). Mirrors the banner-line warning so you can't forget you're in auto-approve mode. |
The bar adapts to terminal width — full layout at ≥ 76 columns, compact at 52–75, minimal (model + duration, plus the YOLO badge when active) below 52.
+2
View File
@@ -199,6 +199,8 @@ Unset the variable or pass `--resume <id>` explicitly to override on a per-launc
The TUI's status line tracks agent state in real time:
After a session is named, its title appears as an accent-colored badge at the far-right edge of the status line. The title takes the workspace label's place and truncates on narrow terminals.
| Status | Meaning |
|--------|---------|
| `starting agent…` | Session ID is live; tools and skills still coming online. You can type — messages queue and send when ready. |