diff --git a/tests/tui_gateway/test_gateway_ready_profile.py b/tests/tui_gateway/test_gateway_ready_profile.py new file mode 100644 index 000000000000..5df016d730a0 --- /dev/null +++ b/tests/tui_gateway/test_gateway_ready_profile.py @@ -0,0 +1,211 @@ +"""gateway.ready carries the active launch profile (#36081). + +``hermes -p --tui`` users must see the active profile at a glance in the +status bar, without waiting for a session to exist. The name rides additively +on the startup event both transports already emit: + +- stdio: ``tui_gateway/entry.main`` writes it before entering the read loop; +- WS: ``tui_gateway/ws.handle_ws`` writes it right after connection accept. + +Contract under test: + +- named profile (HERMES_HOME under ``~/.hermes/profiles/``) → the name; +- default home / unrecognized custom home → None, so the stock single-profile + UX renders no profile segment (mirrors the composer prefix suppression); +- key-additive: ``skin`` / ``change_events`` consumers are untouched — old + clients simply ignore the new key. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME + Path.home() — the repo's profile-test pattern.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +class TestResolveLaunchProfile: + def test_default_home_reports_none(self, hermes_home): + from tui_gateway.server import resolve_launch_profile + + assert resolve_launch_profile() is None + + def test_unrecognized_custom_home_reports_none(self, hermes_home, monkeypatch): + from tui_gateway.server import resolve_launch_profile + + custom = hermes_home.parent / "custom-home" + custom.mkdir() + monkeypatch.setenv("HERMES_HOME", str(custom)) + + assert resolve_launch_profile() is None + + def test_named_profile_reports_name(self, hermes_home, monkeypatch): + from tui_gateway.server import resolve_launch_profile + + named = Path.home() / ".hermes" / "profiles" / "work" + named.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(named)) + + assert resolve_launch_profile() == "work" + + def test_import_failure_fails_closed_to_none(self, hermes_home, monkeypatch): + """A profiles-module hiccup must never break the ready write.""" + from tui_gateway.server import resolve_launch_profile + + named = Path.home() / ".hermes" / "profiles" / "work" + named.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(named)) + monkeypatch.setitem(__import__("sys").modules, "hermes_cli.profiles", None) + + assert resolve_launch_profile() is None + + +# ── stdio transport: entry.main's ready frame ──────────────────────────────── + + +def _run_entry_main(monkeypatch) -> list[dict]: + """Run entry.main() with stubbed collaborators; return every written frame. + + Harness mirrors tests/tui_gateway/test_entry_picker_prewarm.py: stubbed + I/O collaborators, no subprocess, no real gateway; main() returns on the + genuine stdin EOF. + """ + import io + + from tui_gateway import entry + + writes: list[dict] = [] + + monkeypatch.setattr(entry, "_install_sidecar_publisher", lambda: None) + monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None) + monkeypatch.setattr(entry, "_log_exit", lambda reason: None) + # Genuine EOF — empty stdin drops main() straight out of its read loop. + monkeypatch.setattr(entry.sys, "stdin", io.StringIO("")) + monkeypatch.setattr(entry, "handle_spurious_eof", lambda *a: False) + monkeypatch.setattr(entry.server, "_ensure_skin_watcher", lambda: None) + + def _write_json(payload): + writes.append(payload) + return True + + monkeypatch.setattr(entry, "write_json", _write_json) + + # The prewarm helper is imported lazily from its own module inside main(). + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "prewarm_picker_cache_async", lambda: None) + + entry.main() + + return writes + + +def test_entry_ready_payload_carries_named_profile(monkeypatch, hermes_home): + from tui_gateway.server import resolve_skin + + named = Path.home() / ".hermes" / "profiles" / "work" + named.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(named)) + + frames = _run_entry_main(monkeypatch) + ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready") + payload = ready["params"]["payload"] + + assert payload["profile"] == "work" + # Additive: the pre-existing keys ride along untouched. + assert payload["change_events"] is True + assert payload["skin"] == resolve_skin() + + +def test_entry_ready_payload_omits_default_profile(monkeypatch, hermes_home): + from tui_gateway import entry + + # Patch on entry itself — main() calls the name it imported at module load. + monkeypatch.setattr(entry, "resolve_skin", lambda: {"name": "default"}) + frames = _run_entry_main(monkeypatch) + + ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready") + payload = ready["params"]["payload"] + + assert payload["profile"] is None + assert payload["change_events"] is True + assert payload["skin"] == {"name": "default"} + + +# ── WebSocket transport: handle_ws's ready frame ───────────────────────────── + + +class _ReadyCaptureWS: + """FakeWS that records every line written and disconnects immediately.""" + + def __init__(self): + self.lines: list[str] = [] + + async def accept(self): + pass + + async def send_text(self, line): + self.lines.append(line) + + async def receive_text(self): + import tui_gateway.ws as ws_mod + + raise ws_mod._WebSocketDisconnect() + + async def close(self): + pass + + +def _run_handle_ws(monkeypatch) -> list[dict]: + """Drive the REAL handle_ws through its ready write; return decoded frames.""" + import tui_gateway.ws as ws_mod + + monkeypatch.setattr( + "tui_gateway.server._ensure_skin_watcher", lambda: None + ) + + fake = _ReadyCaptureWS() + asyncio.run(ws_mod.handle_ws(fake)) + + return [json.loads(line) for line in fake.lines] + + +def test_ws_ready_frame_carries_named_profile(monkeypatch, hermes_home): + import tui_gateway.server as server_mod + + named = Path.home() / ".hermes" / "profiles" / "work" + named.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(named)) + monkeypatch.setattr(server_mod, "resolve_skin", lambda: {"palette": "wired"}) + + frames = _run_handle_ws(monkeypatch) + ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready") + payload = ready["params"]["payload"] + + assert payload["profile"] == "work" + assert payload["change_events"] is True + assert payload["skin"] == {"palette": "wired"} + + +def test_ws_ready_frame_reports_none_for_default_home(monkeypatch, hermes_home): + import tui_gateway.server as server_mod + + monkeypatch.setattr(server_mod, "resolve_skin", lambda: {"name": "default"}) + + frames = _run_handle_ws(monkeypatch) + ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready") + payload = ready["params"]["payload"] + + assert payload["profile"] is None + assert payload["change_events"] is True diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 00b801011b30..6078d8ec56b1 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -20,7 +20,13 @@ from tui_gateway._stdin_recovery import handle_spurious_eof from tui_gateway import server -from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json +from tui_gateway.server import ( + _CRASH_LOG, + dispatch, + resolve_launch_profile, + resolve_skin, + write_json, +) from tui_gateway.transport import TeeTransport logger = logging.getLogger(__name__) @@ -437,7 +443,13 @@ def main(): "params": { "type": "gateway.ready", # change_events: see tui_gateway/ws.py — clients demote legacy polls. - "payload": {"skin": resolve_skin(), "change_events": True}, + # profile: active launch profile, or None on the default home so the + # status bar's profile segment stays hidden there (#36081). + "payload": { + "skin": resolve_skin(), + "change_events": True, + "profile": resolve_launch_profile(), + }, }, }): _log_exit("startup write failed (broken stdout pipe before first event)") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 48d8039c53e7..fcf9f0a24b74 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3746,6 +3746,24 @@ def resolve_skin() -> dict: return {} +def resolve_launch_profile() -> str | None: + """Active launch-profile name for the ``gateway.ready`` payload (#36081). + + Returns the profile name when ``HERMES_HOME`` points into + ``~/.hermes/profiles/``; None for the default home and for + unrecognized custom homes — the stock single-profile UX must not grow a + profile segment, matching how the composer prompt prefix suppresses them. + Additive + optional: clients that don't know the key are unaffected. + """ + try: + from hermes_cli.profiles import get_active_profile_name + + name = get_active_profile_name() + except Exception: + return None + return name if name not in ("", "custom", "default") else None + + # Signature of the last skin broadcast: (name, active user-file mtime). Lets the # per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a # live color edit to the active skin — and nothing else. diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 1afaba0991a1..393eb76fc46d 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -341,6 +341,7 @@ async def handle_ws( # (#60800). The skin payload is small (a dict of strings/arrays), # so the to_thread overhead is negligible. skin_payload = await asyncio.to_thread(server.resolve_skin) + profile_payload = await asyncio.to_thread(server.resolve_launch_profile) ready_ok = await transport.write_async( { "jsonrpc": "2.0", @@ -350,7 +351,13 @@ async def handle_ws( # change_events: this backend broadcasts pet.changed / # cron.changed / sessions.changed, so clients can demote # their legacy polls to slow backstops. - "payload": {"skin": skin_payload, "change_events": True}, + # profile: active launch profile, None on the default home + # (#36081) — same additive contract as the stdio entry. + "payload": { + "skin": skin_payload, + "change_events": True, + "profile": profile_payload, + }, }, } ) diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 07fc46876899..d92e1b6191f9 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -491,3 +491,48 @@ describe('StatusRule idle-since read-out', () => { expect(findComponentByName(element, 'IdleSince')).toBeNull() }) }) + +describe('StatusRule active profile segment', () => { + it('is hidden by default (no profile reported → stock UX unchanged)', () => { + const element = StatusRule({ ...baseProps }) + + expect(textContent(element)).not.toContain('mlperf') + }) + + it('shows the named profile between the context bar and the clocks', () => { + const element = StatusRule({ + ...baseProps, + profileName: 'mlperf', + sessionStartedAt: Date.now() - 60_000 + }) + + expect(textContent(element)).toContain('mlperf') + // The elapsed clock still renders after it — ordering sanity, not width. + expect(textContent(element)).toContain('%') + }) + + it('styles the segment like its muted neighbours (muted colour, truncate-end)', () => { + const element = StatusRule({ ...baseProps, profileName: 'mlperf' }) + + const leaf = findElementWithText(element, 'mlperf') + + expect(leaf?.props.color).toBe(DEFAULT_THEME.color.muted) + expect(leaf?.props.wrap).toBe('truncate-end') + }) + + it.each(['default', 'custom'])('suppresses the non-profile name "%s" like the composer prefix does', name => { + const element = StatusRule({ ...baseProps, profileName: name }) + + // The names are common words — assert on the separator-prefixed segment + // shape so a stray occurrence elsewhere can't false-positive. + expect(textContent(element)).not.toContain(` │ ${name}`) + }) + + it('drops the whole segment below its breakpoint instead of truncating mid-name', () => { + const element = StatusRule({ ...baseProps, cols: 44, profileName: 'mlperf' }) + + expect(textContent(element)).not.toContain('mlperf') + // Essentials survive untouched. + expect(textContent(element)).toContain('opus 4.8') + }) +}) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index d36dbfa224a1..cc05ef92de3f 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1001,6 +1001,28 @@ describe('createGatewayEventHandler', () => { expect(resumeById).not.toHaveBeenCalled() }) + it('gateway.ready records the launch profile for the status bar', () => { + const ctx = buildCtx([]) + + createGatewayEventHandler(ctx)({ + payload: { profile: 'mlperf', skin: {} }, + type: 'gateway.ready' + } as any) + + expect(getUiState().launchProfile).toBe('mlperf') + }) + + it('gateway.ready without a profile keeps launchProfile null (default home)', () => { + const ctx = buildCtx([]) + + createGatewayEventHandler(ctx)({ + payload: { profile: null }, + type: 'gateway.ready' + } as any) + + expect(getUiState().launchProfile).toBeNull() + }) + it('on gateway.ready after a crash, resumes the recovered session once and skips forge', async () => { const appended: Msg[] = [] const newSession = vi.fn() diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 1a5334eb13d9..6b859f2aab38 100644 --- a/ui-tui/src/__tests__/statusRule.test.ts +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -65,6 +65,7 @@ describe('statusBarSegments', () => { expect(s).toEqual({ compactCtx: false, bar: true, + profile: true, duration: true, compressions: true, voice: true, @@ -78,6 +79,7 @@ describe('statusBarSegments', () => { expect(s.compactCtx).toBe(true) expect(s.bar).toBe(false) + expect(s.profile).toBe(false) expect(s.duration).toBe(false) }) @@ -85,6 +87,7 @@ describe('statusBarSegments', () => { // the context bar is the last of the tail to go. const order: (keyof ReturnType)[] = [ 'bar', + 'profile', 'duration', 'compressions', 'voice', diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 78fb1ed5c33f..6aa3903d9553 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -647,11 +647,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running') - const handleReady = (skin?: GatewaySkin) => { + const handleReady = ({ profile, skin }: { profile?: null | string; skin?: GatewaySkin } = {}) => { if (skin) { applySkin(skin) } + // Launch-profile identity for the status bar (#36081). Null/absent on the + // default home → the segment stays hidden. A later session.info carries + // its own profile_name, which the status bar prefers once known. + patchUiState({ launchProfile: profile ?? null }) + // Kick off the config fetch once the gateway is actually ready. If handler // construction does this during React render, a startup transport error can // report through sys(), mutate transcript state, and trip React's @@ -759,7 +764,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: switch (ev.type) { case 'gateway.ready': - handleReady(ev.payload?.skin) + handleReady(ev.payload) return diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1a4cfbc5657c..65474d5a012c 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -329,6 +329,10 @@ export interface UiState { // persistent `◉ focus` status-bar badge; never affects request payloads. focusView: boolean info: null | SessionInfo + // Launch profile reported by gateway.ready (`profile`, #36081). Null on the + // default home so the status bar's profile segment stays hidden there. This + // is the pre-session fallback — a session.info profile_name wins once known. + launchProfile: null | string liveSessionCount: number inlineDiffs: boolean mouseTracking: MouseTrackingMode diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 581d576a48db..c03d9161424b 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -20,6 +20,7 @@ const buildUiState = (): UiState => ({ focusView: false, indicatorStyle: DEFAULT_INDICATOR_STYLE, info: null, + launchProfile: null, liveSessionCount: 0, inlineDiffs: true, mouseTracking: MOUSE_TRACKING, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 708391eea683..70389a0174d9 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -12,6 +12,7 @@ import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' +import { showsNamedProfile } from '../lib/prompt.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' @@ -294,6 +295,7 @@ export interface StatusBarSegments { compactCtx: boolean compressions: boolean duration: boolean + profile: boolean subagents: boolean voice: boolean } @@ -304,6 +306,9 @@ export function statusBarSegments(cols: number): StatusBarSegments { return { compactCtx: w < 72, bar: w >= 72, + // Active-profile identity only ever renders for a real named profile, so + // it shares the context-bar tier — visible whenever the bar is. + profile: w >= 72, duration: w >= 76, compressions: w >= 80, voice: w >= 84, @@ -476,6 +481,7 @@ export function StatusRule({ modelReasoningEffort, indicatorStyle = 'kaomoji', notice, + profileName, usage, bgCount, lastTurnEndedAt, @@ -575,6 +581,12 @@ export function StatusRule({ : '' const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`)) + // Active-profile identity (#36081). Hidden unless the backend reported a + // real named profile — `default`/custom homes render nothing, keeping the + // stock UX unchanged. Budgeted like every tail segment (evaluated ahead of + // the elapsed clocks: knowing WHICH instance you're on outranks its uptime) + // so it drops whole on a narrow terminal instead of crushing model │ ctx. + const showProfile = segs.profile && showsNamedProfile(profileName) && fits(SEP + stringWidth(profileName!)) const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH) // Idle clock — time since the last final agent response. Hidden while busy @@ -685,6 +697,12 @@ export function StatusRule({ [{bar}] {pct != null ? `${pct}%` : ''} ) : null} + {showProfile ? ( + + {' │ '} + {profileName} + + ) : null} {showDuration ? ( {' │ '} @@ -869,6 +887,9 @@ interface StatusRuleProps { modelReasoningEffort?: string indicatorStyle?: IndicatorStyle notice?: Notice | null + // Active launch profile (#36081). Null/absent on the default home → the + // segment renders nothing, so the stock single-profile UX is unchanged. + profileName?: null | string sessionStartedAt?: null | number sessionTitle?: string status: string diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index f35809769967..a3d349196a68 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -503,6 +503,7 @@ const StatusRulePane = memo(function StatusRulePane({ modelReasoningEffort={ui.info?.reasoning_effort} notice={ui.notice} onSessionCountClick={() => patchOverlayState({ sessions: true })} + profileName={ui.info?.profile_name ?? ui.launchProfile} sessionStartedAt={status.sessionStartedAt} sessionTitle={status.sessionTitle} status={ui.status} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 427af8badece..514d079d62fc 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -611,7 +611,12 @@ export interface SpawnTreeLoadResponse { } export type GatewayEvent = - | { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' } + | { + /** Active launch profile; null/absent on the default home (#36081). */ + payload?: { profile?: null | string; skin?: GatewaySkin } + session_id?: string + type: 'gateway.ready' + } | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } diff --git a/ui-tui/src/lib/prompt.ts b/ui-tui/src/lib/prompt.ts index 27b30474c02d..a00cf6f5b255 100644 --- a/ui-tui/src/lib/prompt.ts +++ b/ui-tui/src/lib/prompt.ts @@ -1,5 +1,15 @@ const TERMUX_SAFE_PROMPT = '>' +// Profile names that never surface as identity chrome: `default` is the stock +// instance and `custom` marks an unrecognized HERMES_HOME — neither is a real +// named profile. Shared by the composer prefix and the status-bar segment so +// the two always agree on when a profile is displayable (#36081). +const HIDDEN_PROFILE_NAMES = ['default', 'custom'] + +export function showsNamedProfile(profileName?: null | string): profileName is string { + return !!profileName && !HIDDEN_PROFILE_NAMES.includes(profileName) +} + export function composerPromptText( prompt: string, profileName?: null | string, @@ -21,14 +31,14 @@ export function composerPromptText( // panes this burns precious columns and increases wrap/clipping risk. const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false - if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) { + if (wideEnoughForProfile && showsNamedProfile(profileName)) { return `${profileName} ${basePrompt}` } return basePrompt } - if (profileName && !['default', 'custom'].includes(profileName)) { + if (showsNamedProfile(profileName)) { return `${profileName} ${prompt}` }