diff --git a/src/app.rs b/src/app.rs index b9604b7..6822f4e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { + 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::().ok()?; + return Some(value.min(100) as u8); + } + continue; + } + idx += 1; + } + None +} + fn should_ignore_delete_shortcut(suppress_delete_until: &mut Option) -> bool { let Some(until) = *suppress_delete_until else { return false; @@ -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() { @@ -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 @@ -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 { diff --git a/src/hooks/event.rs b/src/hooks/event.rs index 67d00b8..6927ce1 100644 --- a/src/hooks/event.rs +++ b/src/hooks/event.rs @@ -38,6 +38,9 @@ pub enum HookEvent { timestamp: Option>, #[serde(default)] message: Option, + /// Optional context usage percentage reported by hook payloads. + #[serde(default)] + context_usage_percent: Option, }, /// A subagent stopped within a Claude Code session. SubagentStopped { @@ -72,6 +75,47 @@ impl HookEvent { pub fn from_json(json: &str) -> Result { serde_json::from_str(json) } + + /// Best-effort context usage percentage (0-100) for notification events. + pub fn context_usage_percent(&self) -> Option { + 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 { + 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::().ok()?; + return Some(value.min(100) as u8); + } + continue; + } + idx += 1; + } + None } #[cfg(test)] @@ -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)); + } } diff --git a/src/hooks/install.rs b/src/hooks/install.rs index d1c4711..8fc42b9 100644 --- a/src/hooks/install.rs +++ b/src/hooks/install.rs @@ -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 diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index bc733b5..a667541 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -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, +) { + render_with_remotes(f, area, message, worktree_count, &[], context_usage_percent); } /// Render the status bar with optional remote host status indicators. @@ -19,6 +25,7 @@ pub fn render_with_remotes( message: &str, worktree_count: usize, remote_statuses: &[crate::remote::host::RemoteHostStatus], + context_usage_percent: Option, ) { let mut spans = vec![ Span::styled( @@ -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); @@ -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(); @@ -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%")); + } } diff --git a/src/worktree/model.rs b/src/worktree/model.rs index fa203db..150ca6e 100644 --- a/src/worktree/model.rs +++ b/src/worktree/model.rs @@ -43,6 +43,9 @@ pub struct Worktree { pub tmux_pane: Option, #[serde(default)] pub status: WorktreeStatus, + /// Latest Claude context usage percentage (0-100), when available from hooks. + #[serde(default)] + pub context_usage_percent: Option, /// PR number on GitHub (if a PR has been created). #[serde(default)] pub pr_number: Option, @@ -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,