namespace ClaudeOverview.Models; /// Whether the chat is mid-work or has handed the turn back to the human. public enum ChatStatus { Working, WaitingForUser, /// Claude is blocked on a tool-permission prompt, waiting for the human to approve. WaitingForPermission } /// /// A single Claude Code chat (one transcript .jsonl file) plus its aggregated usage. /// public sealed class ClaudeSession { public string SessionId { get; init; } = ""; public string FilePath { get; init; } = ""; /// Working directory the chat ran in (cwd from the transcript). public string Cwd { get; set; } = ""; public string GitBranch { get; set; } = ""; public string Model { get; set; } = ""; public string Entrypoint { get; set; } = ""; /// First human message, used as a fallback human-readable title. public string Title { get; set; } = ""; /// /// The chat name VS Code generates and shows in its tab (an "ai-title" entry in the /// transcript). Empty until the extension has generated one for the chat. /// public string AiTitle { get; set; } = ""; /// The name to show for the chat: the VS Code title when available, else the first prompt. public string DisplayTitle => AiTitle.Length > 0 ? AiTitle : Title; public DateTime LastActivityUtc { get; set; } /// /// Set by the scanner: true when the chat changed within the active window. Cold (false) chats /// are still listed, but rendered dimmed and without status colors. /// public bool IsActive { get; set; } = true; /// True for cold chats — listed for context but no longer actively running. public bool IsCold => !IsActive; // Aggregated token usage across all assistant messages in this chat. public long InputTokens { get; set; } public long OutputTokens { get; set; } public long CacheReadTokens { get; set; } public long CacheCreationTokens { get; set; } public int MessageCount { get; set; } public long TotalTokens => InputTokens + OutputTokens + CacheReadTokens + CacheCreationTokens; /// /// Tokens this chat consumed since the current session-limit window started (set by the scanner). /// Used to approximate each chat's slice of the session limit. /// public long SessionTokens { get; set; } /// This chat's share (0–100) of all tokens consumed across listed chats this window. public double SessionSharePercent { get; set; } public string SessionShareText => $"{SessionSharePercent:0}%"; // ----- State of the last conversational turn (set by the scanner) ----- /// Role of the last main-chain message ("assistant" or "user"). public string LastRole { get; set; } = ""; /// stop_reason of the last assistant message (e.g. "end_turn", "tool_use"). public string LastStopReason { get; set; } = ""; /// Tool names invoked by the last assistant message. public List LastToolNames { get; set; } = new(); /// Primary argument of each tool in (command / path / url), index-aligned. public List LastToolArgs { get; set; } = new(); /// /// Set by the scanner: true when every pending tool of the last assistant turn is auto-approved /// by the user's Claude Code permission settings. When so, a stale tool_use means a long-running /// approved tool, not a prompt — so we keep showing "working…" instead of "needs permission". /// public bool PendingToolsAutoApproved { get; set; } /// /// Set by the scanner: true when the last assistant tool_use has NOT yet been answered by a /// tool_result. Once a result arrives (even if Claude then thinks for a while before its next /// line), the tool clearly ran — so a quick command followed by long thinking is never mistaken /// for a permission prompt. /// public bool AwaitingToolResult { get; set; } /// Text of the last assistant message (used to detect a literal question). public string LastAssistantText { get; set; } = ""; /// /// How long a pending tool_use must sit without a following tool_result before we treat it /// as a permission prompt (Claude blocked, waiting on the human) rather than a tool still /// running. Set once at startup from config. Kept small so the widget flags prompts quickly. /// public static int PendingToolStaleSeconds = 8; public ChatStatus Status { get { if (LastRole == "assistant") { // An interactive prompt tool means Claude is explicitly asking the user. if (LastToolNames.Contains("AskUserQuestion") || LastToolNames.Contains("ExitPlanMode")) return ChatStatus.WaitingForUser; // Claude ended its turn and yielded back to the human. if (LastStopReason is "end_turn" or "stop_sequence") return ChatStatus.WaitingForUser; // A trailing tool_use with no tool_result yet: the tool is either executing or // Claude Code is blocked on a permission prompt. The transcript can't tell these // apart directly, so we combine three signals: the tool_use is still unanswered // (a returned result means it ran — long post-tool thinking is NOT a prompt), it // isn't auto-approved by the user's permission settings (otherwise it'd just run), // AND the file has sat untouched past the stale threshold. if (AwaitingToolResult && LastToolNames.Count > 0 && !PendingToolsAutoApproved && DateTime.UtcNow - LastActivityUtc > TimeSpan.FromSeconds(PendingToolStaleSeconds)) return ChatStatus.WaitingForPermission; // tool_use (still fresh) / max_tokens etc. — still mid-work. return ChatStatus.Working; } // Last entry is a user/tool-result message: Claude is generating. return ChatStatus.Working; } } /// True whenever the chat needs the human — finished, asking, or awaiting permission. public bool IsWaiting => Status != ChatStatus.Working; /// True specifically when Claude is blocked on a tool-permission prompt. public bool NeedsPermission => Status == ChatStatus.WaitingForPermission; /// True when Claude is specifically posing a question (vs just finishing). public bool AsksQuestion => IsWaiting && (LastToolNames.Contains("AskUserQuestion") || LastAssistantText.TrimEnd().EndsWith("?")); public string StatusText => Status switch { ChatStatus.WaitingForPermission => LastToolNames.Count > 0 ? $"allow {LastToolNames[^1]}?" : "needs permission", ChatStatus.WaitingForUser => AsksQuestion ? "question for you" : "idle", _ => "working…" }; // ----- Display helpers (bound directly in XAML) ----- public string ProjectName { get { if (string.IsNullOrEmpty(Cwd)) return "(unknown)"; var trimmed = Cwd.TrimEnd('/', '\\'); var idx = trimmed.LastIndexOfAny(new[] { '/', '\\' }); return idx >= 0 && idx < trimmed.Length - 1 ? trimmed[(idx + 1)..] : trimmed; } } public bool IsVsCode => Entrypoint == "claude-vscode"; public string ModelShort => Model .Replace("claude-", "") .Replace("-20", " 20"); // cosmetic public string LastActivityLocal { get { var span = DateTime.UtcNow - LastActivityUtc; if (span < TimeSpan.FromSeconds(60)) return "just now"; if (span < TimeSpan.FromMinutes(60)) return $"{(int)span.TotalMinutes}m ago"; if (span < TimeSpan.FromHours(24)) return $"{(int)span.TotalHours}h ago"; return LastActivityUtc.ToLocalTime().ToString("MMM d HH:mm"); } } public string UsageSummary => $"{Human(TotalTokens)} tok · {MessageCount} msg"; public string UsageDetail => $"in {Human(InputTokens)} · out {Human(OutputTokens)} · cache {Human(CacheReadTokens)}"; private static string Human(long n) => n switch { >= 1_000_000 => $"{n / 1_000_000.0:0.0}M", >= 1_000 => $"{n / 1_000.0:0.0}k", _ => n.ToString() }; }