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]