Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand Down Expand Up @@ -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
48 changes: 46 additions & 2 deletions hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 ''

Expand All @@ -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)

Expand Down
109 changes: 109 additions & 0 deletions tests/test_hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Loading