feat(cli): show session titles in status bars
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -585,6 +585,7 @@ export interface AppLayoutStatusProps {
|
||||
goodVibesTick: number
|
||||
lastTurnEndedAt: null | number
|
||||
sessionStartedAt: null | number
|
||||
sessionTitle: string
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
stickyPrompt: string
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user