From 8b9fd495f7704036dbf753215f81ce918af81573 Mon Sep 17 00:00:00 2001 From: staryxchen Date: Fri, 17 Jul 2026 08:04:53 +0000 Subject: [PATCH] feat: show session name on HUD line 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append the current session name as a trailing segment on line 1, so the status line reads e.g. `[Model] │ folder git:(branch*) │ session: name`. The segment is omitted entirely when the session has no title. The name is resolved from the transcript by get_session_name(): the last `custom-title` entry's customTitle (set via the built-in /rename action), falling back to the last `ai-title` entry's aiTitle (auto-generated). This matches the UI's getCustomTitle semantics. Title entries are read via a full-file `grep -E 'custom-title|ai-title'` rather than the 800-line tail window, since a rename can fall outside that window in a long session and title entries are sparse. As with the rest of the script, any failure (missing file, grep timeout) returns '' and the segment is omitted — the status line never crashes. The `session:` label is CYAN (matching line-1 structural labels `folder` and `git:`) and the name is MAGENTA, keeping it distinct from the YELLOW branch. Tests: 8 unit tests for get_session_name (missing file, empty, no titles, custom returned, last custom wins, ai fallback, custom preferred over ai, empty custom falls back to ai) plus 3 end-to-end tests. 41 tests pass. Signed-off-by: staryxchen --- README.md | 7 ++- hud.py | 48 +++++++++++++++++++- tests/test_hud.py | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 66e776e..3b8c082 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A HUD-style status line for [CodeBuddy Code](https://cnb.cool/codebuddy/codebudd ## What it shows ``` -[Claude-Sonnet-4.6-1M] │ my-project git:(main*) +[Claude-Sonnet-4.6-1M] │ my-project git:(main*) │ session: PR#30777 Context ████░░░░░░ 41.8% ✓ Bash ×32 ✓ Read ×8 ✓ Edit ×5 ✓ WebFetch ×3 ✓ Grep ×2 ``` @@ -19,11 +19,13 @@ Context ████░░░░░░ 41.8% | Model name | `model.display_name` from stdin | | Folder name | `workspace.current_dir` from stdin | | Git branch + dirty (`*`) | `git branch --show-current` | +| Session name | Last `custom-title` (fallback `ai-title`) entry in transcript | | Context bar + % | `context_window.used_percentage` from stdin | | Tool stats (Line 3) | Completed `function_call` entries in transcript, top 5 by count | Context bar color: green < 70%, yellow 70–85%, red ≥ 85%. -Line 3 shows the top 5 most-used tools this session; hidden when no tools have run. +Line 3 shows the top 5 most-used tools this session; hidden when no tools have run. +The session name is hidden when no title has been set (via `/rename` or auto-generated). ## Installation @@ -72,3 +74,4 @@ ln -sf ~/codebuddy-hud/hud.py ~/.codebuddy/hud.py - Python 3.7+ - `git` in PATH - `tail` in PATH +- `grep` in PATH diff --git a/hud.py b/hud.py index d1143e3..bc0529f 100644 --- a/hud.py +++ b/hud.py @@ -3,10 +3,13 @@ codebuddy-hud: A HUD-style status line for CodeBuddy Code. Visual style aligned with claude-hud (github.com/jarrodwatts/claude-hud). -Line 1: [model] │ folder git:(branch*) +Line 1: [model] │ folder git:(branch*) │ session: session-name Line 2: Context [████░░░░░░] pct% Line 3: ✓ Bash ×32 ✓ Read ×5 ✓ Edit ×3 (top-5 tool usage stats, by count) +The session-name segment on line 1 is shown only when the session has a title +(set via /rename, or auto-generated as an ai-title); otherwise it is omitted. + Install: ln -sf /data/workspace/codebuddy-hud/hud.py ~/.codebuddy/hud.py @@ -82,6 +85,41 @@ def _read_transcript_tail(transcript_path: str, n: int = 800) -> list: return [] +# --------------------------------------------------------------------------- +# Resolve the current session name from the transcript. +# The name is the last 'custom-title' entry's customTitle (set via /rename), +# falling back to the last 'ai-title' entry's aiTitle (auto-generated). +# Returns '' when no title entry exists (line-1 segment is then omitted). +# +# grep is used (not _read_transcript_tail) because a rename can fall outside +# the 800-line tail window in a long session; title entries are sparse, so a +# single full-file grep is cheap and only loads matching lines. +# --------------------------------------------------------------------------- +def get_session_name(transcript_path: str) -> str: + if not transcript_path or not os.path.exists(transcript_path): + return '' + try: + result = subprocess.run( + ['grep', '-E', 'custom-title|ai-title', transcript_path], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1 + ) + except Exception: + return '' + custom = '' + ai = '' + for raw in result.stdout.decode('utf-8', errors='replace').splitlines(): + try: + entry = json.loads(raw) + except Exception: + continue + etype = entry.get('type', '') + if etype == 'custom-title': + custom = entry.get('customTitle', '') # last wins + elif etype == 'ai-title': + ai = entry.get('aiTitle', '') # last wins + return custom or ai or '' + + # --------------------------------------------------------------------------- # Get completed tool usage counts from transcript. # Returns {tool_name: count} @@ -199,7 +237,11 @@ def main(): # Tool usage counts tool_counts = get_tool_counts(transcript) if transcript else {} - # ── Line 1: [model] │ folder git:(branch*) ────────────────────────── + # Session name (last custom-title, fallback ai-title, from the transcript) + session_name = get_session_name(transcript) if transcript else '' + session_part = f"{CYAN}session:{RESET} {MAGENTA}{session_name}{RESET}" if session_name else '' + + # ── Line 1: [model] │ folder git:(branch*) │ session: name ──────── model_part = f"{WHITE}[{model_name}]{RESET}" folder_part = f"{CYAN}{folder}{RESET}" if folder else '' @@ -209,6 +251,8 @@ def main(): if git_str: folder_section += f" {git_str}" parts1.append(folder_section) + if session_part: + parts1.append(session_part) line1 = SEP.join(parts1) diff --git a/tests/test_hud.py b/tests/test_hud.py index 6ec2c89..d01700c 100644 --- a/tests/test_hud.py +++ b/tests/test_hud.py @@ -154,6 +154,87 @@ def test_mixed_tools(self): os.unlink(path) +# ── get_session_name ───────────────────────────────────────────────────────── + +class TestGetSessionName: + def test_missing_file(self): + assert hud.get_session_name('/nonexistent.jsonl') == '' + + def test_empty_transcript(self): + path = make_transcript([]) + try: + assert hud.get_session_name(path) == '' + finally: + os.unlink(path) + + def test_no_title_entries(self): + entries = [ + {'type': 'function_call', 'callId': 'c1', 'name': 'Bash', 'arguments': '{}'}, + {'type': 'function_call_result', 'callId': 'c1', 'status': 'completed'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == '' + finally: + os.unlink(path) + + def test_custom_title_returned(self): + entries = [ + {'type': 'custom-title', 'customTitle': 'PR#30777', + 'sessionId': 's1', 'cwd': '/tmp'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == 'PR#30777' + finally: + os.unlink(path) + + def test_last_custom_title_wins(self): + entries = [ + {'type': 'custom-title', 'customTitle': 'old-name', 'sessionId': 's1'}, + {'type': 'custom-title', 'customTitle': 'new-name', 'sessionId': 's1'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == 'new-name' + finally: + os.unlink(path) + + def test_ai_title_fallback(self): + entries = [ + {'type': 'ai-title', 'aiTitle': 'Review pull request #2820', + 'sessionId': 's1', 'cwd': '/tmp'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == 'Review pull request #2820' + finally: + os.unlink(path) + + def test_custom_title_preferred_over_ai_title(self): + # ai-title appears AFTER custom-title — custom still wins. + entries = [ + {'type': 'custom-title', 'customTitle': 'MyName', 'sessionId': 's1'}, + {'type': 'ai-title', 'aiTitle': 'auto-gen', 'sessionId': 's1'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == 'MyName' + finally: + os.unlink(path) + + def test_empty_custom_title_falls_back_to_ai(self): + entries = [ + {'type': 'custom-title', 'customTitle': '', 'sessionId': 's1'}, + {'type': 'ai-title', 'aiTitle': 'auto-gen', 'sessionId': 's1'}, + ] + path = make_transcript(entries) + try: + assert hud.get_session_name(path) == 'auto-gen' + finally: + os.unlink(path) + + # ── fmt_tools_line ──────────────────────────────────────────────────────────── class TestFmtToolsLine: @@ -288,3 +369,31 @@ def test_invalid_stdin_outputs_blank(self): ) assert result.returncode == 0 assert result.stdout.decode().strip() == '' + + def test_line1_contains_session_name(self): + entries = [ + {'type': 'custom-title', 'customTitle': 'PR#30777', + 'sessionId': 's1', 'cwd': '/tmp'}, + ] + out = strip_ansi(run_hud(BASE_STDIN, transcript_entries=entries)) + lines = [l for l in out.splitlines() if l.strip()] + # Session name appears at the end of line 1 with a 'session:' prefix, + # after a second │ separator. + assert 'session: PR#30777' in lines[0] + assert lines[0].count('│') == 2 + + def test_line1_omits_session_when_unnamed(self): + # No title entries → no trailing separator, exactly one │ on line 1. + out = strip_ansi(run_hud(BASE_STDIN, transcript_entries=[])) + lines = [l for l in out.splitlines() if l.strip()] + assert lines[0].count('│') == 1 + assert not lines[0].rstrip().endswith('│') + + def test_ai_title_shown_end_to_end(self): + entries = [ + {'type': 'ai-title', 'aiTitle': 'Review pull request #2820', + 'sessionId': 's1', 'cwd': '/tmp'}, + ] + out = strip_ansi(run_hud(BASE_STDIN, transcript_entries=entries)) + lines = [l for l in out.splitlines() if l.strip()] + assert 'Review pull request #2820' in lines[0]