Files
vmux/state.go
Pierre Martin 001c453462 fix: improve state detection accuracy and notification UX
- Hook reads JSON from stdin (not env vars) matching Claude Code protocol
- end_turn = Idle (not NeedsInput); real questions come from hooks
- Permission prompt (stale tool_use) never becomes Idle
- Notifications auto-expire after 10s (--expire-time)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:29:26 +01:00

99 lines
2.3 KiB
Go

package main
import (
"strings"
"time"
)
const IdleThreshold = 60 * time.Second
// PermissionStallThreshold: if a tool_use has been pending this long
// without any new JSONL entry, Claude is likely waiting for user approval.
const PermissionStallThreshold = 10 * time.Second
// DetectState determines the session state from the last JSONL messages.
// The now parameter enables deterministic testing of the idle threshold.
func DetectState(messages []JSONLMessage, now time.Time) SessionState {
if len(messages) == 0 {
return Unknown
}
last := messages[len(messages)-1]
age := time.Duration(0)
if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil {
age = now.Sub(ts)
}
if last.Type == "assistant" && last.Message != nil {
switch last.Message.StopReason {
case "end_turn":
return Idle
case "tool_use":
for _, block := range last.Message.Content {
if block.Type == "tool_use" && block.Name == "AskUserQuestion" {
return NeedsInput
}
}
// Stale tool_use = permission prompt waiting for approval.
// Never becomes Idle: a pending permission is always NeedsInput.
if age > PermissionStallThreshold {
return NeedsInput
}
return Working
}
}
// Non-assistant messages (progress, user): idle if old enough
if age > IdleThreshold {
return Idle
}
if last.Type == "progress" {
return Working
}
if last.Type == "user" && last.Message != nil {
for _, block := range last.Message.Content {
if block.Type == "tool_result" {
return Working
}
}
}
return Unknown
}
// ExtractPreview finds the last assistant text content and returns
// the first 5 lines, truncated to 300 characters.
func ExtractPreview(messages []JSONLMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
msg := messages[i]
if msg.Type != "assistant" || msg.Message == nil {
continue
}
for _, block := range msg.Message.Content {
if block.Type == "text" && block.Text != "" {
return truncatePreview(block.Text)
}
}
}
return ""
}
const previewLines = 5
const previewMaxChars = 300
func truncatePreview(text string) string {
lines := strings.SplitN(text, "\n", previewLines+1)
if len(lines) > previewLines {
lines = lines[:previewLines]
}
result := strings.Join(lines, "\n")
if len(result) > previewMaxChars {
result = result[:previewMaxChars] + "..."
}
return result
}