diff --git a/cli.py b/cli.py index fa3beb280c..3e1e35e958 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 2279fe4129..7b73569aa6 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -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 diff --git a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx index d683e841ac..56d80a350f 100644 --- a/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx +++ b/ui-tui/src/__tests__/appChromeBlockedTimers.test.tsx @@ -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: '', diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index b3c639846a..ec13205649 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -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({ diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c448df0259..4583ccfe60 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -585,6 +585,7 @@ export interface AppLayoutStatusProps { goodVibesTick: number lastTurnEndedAt: null | number sessionStartedAt: null | number + sessionTitle: string showStickyPrompt: boolean statusColor: string stickyPrompt: string diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 865c9cb5d6..62f64ccaff 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -274,6 +274,7 @@ export const coreCommands: SlashCommand[] = [ ctx.guarded(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}`) }) ) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 283ebe5a11..0756c2fd0e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index ee8fbe5f2c..5839ecdc69 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -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) => { diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 1ff100f6d1..93da0fdaed 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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({ <> {separatorWidth >= 3 ? ' ─ ' : ' '} - - {cwdLabel} + + {rightLabel} @@ -868,6 +875,7 @@ interface StatusRuleProps { indicatorStyle?: IndicatorStyle notice?: Notice | null sessionStartedAt?: null | number + sessionTitle?: string status: string statusColor: string t: Theme diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 660fcb881d..ad4726798f 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 5fbc06d76d..2f3d78fafc 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -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. diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index de62bd36d8..c15ee3f6ed 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -199,6 +199,8 @@ Unset the variable or pass `--resume ` 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. |