Skip to content
Open
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
49 changes: 47 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ fn should_process_key_event(key: &KeyEvent) -> bool {
matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
}

fn parse_context_percent_from_message(message: &str) -> Option<u8> {
let lowered = message.to_ascii_lowercase();
if !lowered.contains("context") {
return None;
}

let bytes = message.as_bytes();
let mut idx = 0usize;
while idx < bytes.len() {
if bytes[idx].is_ascii_digit() {
let start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx < bytes.len() && bytes[idx] == b'%' {
let value = message[start..idx].parse::<u16>().ok()?;
return Some(value.min(100) as u8);
}
continue;
}
idx += 1;
}
None
}

fn should_ignore_delete_shortcut(suppress_delete_until: &mut Option<Instant>) -> bool {
let Some(until) = *suppress_delete_until else {
return false;
Expand Down Expand Up @@ -363,11 +388,22 @@ impl App {
self.persist_session_stop(&worktree, session_id.as_deref());
}
HookEvent::SessionNotification {
worktree, message, ..
worktree,
message,
context_usage_percent,
..
} => {
let context_percent = context_usage_percent.or_else(|| {
message
.as_deref()
.and_then(parse_context_percent_from_message)
});
// Update the worktree status to Waiting
if let Some(wt) = self.worktrees.iter_mut().find(|wt| wt.name == worktree) {
wt.status = WorktreeStatus::Waiting;
if let Some(pct) = context_percent {
wt.context_usage_percent = Some(pct);
}
}
let msg = message.unwrap_or_default();
self.status_message = if msg.is_empty() {
Expand Down Expand Up @@ -566,6 +602,8 @@ impl App {
&self.status_message,
self.worktrees.len(),
&self.remote_statuses,
self.selected_worktree()
.and_then(|wt| wt.context_usage_percent),
);

// Render active dialog on top
Expand Down Expand Up @@ -2822,7 +2860,14 @@ impl ForestApp {
.and_then(|idx| self.repos.get(idx))
.map(|r| r.worktrees.len())
.unwrap_or(0);
ui::status_bar::render(frame, status_area, &self.status_message, repo_wt_count);
let selected_ctx = selected_wt.and_then(|wt| wt.context_usage_percent);
ui::status_bar::render(
frame,
status_area,
&self.status_message,
repo_wt_count,
selected_ctx,
);

// Render active dialog on top
match &self.dialog {
Expand Down
67 changes: 67 additions & 0 deletions src/hooks/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub enum HookEvent {
timestamp: Option<DateTime<Utc>>,
#[serde(default)]
message: Option<String>,
/// Optional context usage percentage reported by hook payloads.
#[serde(default)]
context_usage_percent: Option<u8>,
},
/// A subagent stopped within a Claude Code session.
SubagentStopped {
Expand Down Expand Up @@ -72,6 +75,47 @@ impl HookEvent {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}

/// Best-effort context usage percentage (0-100) for notification events.
pub fn context_usage_percent(&self) -> Option<u8> {
match self {
HookEvent::SessionNotification {
context_usage_percent,
message,
..
} => (*context_usage_percent).or_else(|| {
message
.as_deref()
.and_then(parse_context_percent_from_message)
}),
_ => None,
}
}
}

fn parse_context_percent_from_message(message: &str) -> Option<u8> {
let lowered = message.to_ascii_lowercase();
if !lowered.contains("context") {
return None;
}

let bytes = message.as_bytes();
let mut idx = 0usize;
while idx < bytes.len() {
if bytes[idx].is_ascii_digit() {
let start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx < bytes.len() && bytes[idx] == b'%' {
let value = message[start..idx].parse::<u16>().ok()?;
return Some(value.min(100) as u8);
}
continue;
}
idx += 1;
}
None
}

#[cfg(test)]
Expand Down Expand Up @@ -129,4 +173,27 @@ mod tests {
panic!("expected SessionNotification");
}
}

#[test]
fn notification_context_percent_parses_from_message() {
let json = r#"{
"event": "session_notification",
"worktree": "feature-auth",
"message": "Context window at 87% used"
}"#;
let event = HookEvent::from_json(json).unwrap();
assert_eq!(event.context_usage_percent(), Some(87));
}

#[test]
fn notification_context_percent_uses_explicit_field() {
let json = r#"{
"event": "session_notification",
"worktree": "feature-auth",
"message": "Waiting for input",
"context_usage_percent": 42
}"#;
let event = HookEvent::from_json(json).unwrap();
assert_eq!(event.context_usage_percent(), Some(42));
}
}
21 changes: 20 additions & 1 deletion src/hooks/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,29 @@ fi

# Extract optional fields
SESSION_ID=$(echo "$PAYLOAD" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
MESSAGE=$(echo "$PAYLOAD" | grep -o '"message"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
MESSAGE_ESCAPED=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g')
CONTEXT_PERCENT=$(echo "$PAYLOAD" | grep -o '"context_usage_percent"[[:space:]]*:[[:space:]]*[0-9]\{{1,3\}}' | head -1 | sed 's/.*:[[:space:]]*//')

# Fallback: parse context percent from message text when present (e.g. "Context window at 82% used")
if [ -z "$CONTEXT_PERCENT" ] && [ -n "$MESSAGE" ]; then
case "$(echo "$MESSAGE" | tr '[:upper:]' '[:lower:]')" in
*context*)
CONTEXT_PERCENT=$(echo "$MESSAGE" | grep -o '[0-9]\{{1,3\}}%' | head -1 | tr -d '%')
;;
esac
fi
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Build the cwt event JSON
EVENT='{{"event":"{cwt_event}","worktree":"'"$WORKTREE"'","session_id":"'"$SESSION_ID"'","timestamp":"'"$TIMESTAMP"'"}}'
EVENT='{{"event":"{cwt_event}","worktree":"'"$WORKTREE"'","session_id":"'"$SESSION_ID"'","timestamp":"'"$TIMESTAMP"'"'
if [ -n "$MESSAGE_ESCAPED" ]; then
EVENT="$EVENT,\"message\":\"$MESSAGE_ESCAPED\""
fi
if [ -n "$CONTEXT_PERCENT" ]; then
EVENT="$EVENT,\"context_usage_percent\":$CONTEXT_PERCENT"
fi
EVENT="$EVENT}}"

# Write to socket (best-effort, non-blocking)
printf '%s\n' "$EVENT" | nc -U -w1 "$SOCKET" 2>/dev/null || true
Expand Down
35 changes: 32 additions & 3 deletions src/ui/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ const SHORTCUTS: &str =
" {count} worktree(s) | n:new Ent:session h:handoff P:pr S:ship e:shell p:perm d:del g:gc r:restore t:tasks b:bcast m:mode o:provider ?:help q:quit";

/// Render the status bar at the bottom.
pub fn render(f: &mut Frame, area: Rect, message: &str, worktree_count: usize) {
render_with_remotes(f, area, message, worktree_count, &[]);
pub fn render(
f: &mut Frame,
area: Rect,
message: &str,
worktree_count: usize,
context_usage_percent: Option<u8>,
) {
render_with_remotes(f, area, message, worktree_count, &[], context_usage_percent);
}

/// Render the status bar with optional remote host status indicators.
Expand All @@ -19,6 +25,7 @@ pub fn render_with_remotes(
message: &str,
worktree_count: usize,
remote_statuses: &[crate::remote::host::RemoteHostStatus],
context_usage_percent: Option<u8>,
) {
let mut spans = vec![
Span::styled(
Expand Down Expand Up @@ -57,6 +64,13 @@ pub fn render_with_remotes(
spans.push(Span::raw(" | "));
spans.push(Span::raw(message));
}
if let Some(pct) = context_usage_percent {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
format!("Claude ctx:{}% (/context via hooks)", pct),
Style::default().fg(Color::Yellow),
));
}

let line = Line::from(spans);

Expand All @@ -75,7 +89,7 @@ mod tests {
let mut terminal = Terminal::new(backend).expect("test terminal");

terminal
.draw(|frame| render(frame, frame.area(), message, 3))
.draw(|frame| render(frame, frame.area(), message, 3, None))
.expect("render status bar");

let buffer = terminal.backend().buffer();
Expand All @@ -94,4 +108,19 @@ mod tests {
assert!(line.contains("P:pr"));
assert!(line.contains("Sync in progress"));
}

#[test]
fn shows_claude_context_usage_hint_when_available() {
let backend = TestBackend::new(240, 1);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| render(frame, frame.area(), "", 3, Some(82)))
.expect("render status bar");
let buffer = terminal.backend().buffer();
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, 0)].symbol());
}
assert!(line.contains("Claude ctx:82%"));
}
}
4 changes: 4 additions & 0 deletions src/worktree/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub struct Worktree {
pub tmux_pane: Option<String>,
#[serde(default)]
pub status: WorktreeStatus,
/// Latest Claude context usage percentage (0-100), when available from hooks.
#[serde(default)]
pub context_usage_percent: Option<u8>,
/// PR number on GitHub (if a PR has been created).
#[serde(default)]
pub pr_number: Option<u64>,
Expand Down Expand Up @@ -100,6 +103,7 @@ impl Worktree {
last_session_id: None,
tmux_pane: None,
status: WorktreeStatus::Idle,
context_usage_percent: None,
pr_number: None,
pr_url: None,
pr_status: PrStatus::None,
Expand Down