Files
claude-local-overview-display/Models/ClaudeSession.cs
2026-06-25 09:59:54 +02:00

208 lines
8.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ClaudeOverview.Models;
/// <summary>Whether the chat is mid-work or has handed the turn back to the human.</summary>
public enum ChatStatus
{
Working,
WaitingForUser,
/// <summary>Claude is blocked on a tool-permission prompt, waiting for the human to approve.</summary>
WaitingForPermission
}
/// <summary>
/// A single Claude Code chat (one transcript .jsonl file) plus its aggregated usage.
/// </summary>
public sealed class ClaudeSession
{
public string SessionId { get; init; } = "";
public string FilePath { get; init; } = "";
/// <summary>Working directory the chat ran in (cwd from the transcript).</summary>
public string Cwd { get; set; } = "";
public string GitBranch { get; set; } = "";
public string Model { get; set; } = "";
public string Entrypoint { get; set; } = "";
/// <summary>First human message, used as a fallback human-readable title.</summary>
public string Title { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public string AiTitle { get; set; } = "";
/// <summary>The name to show for the chat: the VS Code title when available, else the first prompt.</summary>
public string DisplayTitle => AiTitle.Length > 0 ? AiTitle : Title;
public DateTime LastActivityUtc { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>True for cold chats — listed for context but no longer actively running.</summary>
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;
/// <summary>
/// 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.
/// </summary>
public long SessionTokens { get; set; }
/// <summary>This chat's share (0100) of all tokens consumed across listed chats this window.</summary>
public double SessionSharePercent { get; set; }
public string SessionShareText => $"{SessionSharePercent:0}%";
// ----- State of the last conversational turn (set by the scanner) -----
/// <summary>Role of the last main-chain message ("assistant" or "user").</summary>
public string LastRole { get; set; } = "";
/// <summary>stop_reason of the last assistant message (e.g. "end_turn", "tool_use").</summary>
public string LastStopReason { get; set; } = "";
/// <summary>Tool names invoked by the last assistant message.</summary>
public List<string> LastToolNames { get; set; } = new();
/// <summary>Primary argument of each tool in <see cref="LastToolNames"/> (command / path / url), index-aligned.</summary>
public List<string> LastToolArgs { get; set; } = new();
/// <summary>
/// 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".
/// </summary>
public bool PendingToolsAutoApproved { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool AwaitingToolResult { get; set; }
/// <summary>Text of the last assistant message (used to detect a literal question).</summary>
public string LastAssistantText { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>True whenever the chat needs the human — finished, asking, or awaiting permission.</summary>
public bool IsWaiting => Status != ChatStatus.Working;
/// <summary>True specifically when Claude is blocked on a tool-permission prompt.</summary>
public bool NeedsPermission => Status == ChatStatus.WaitingForPermission;
/// <summary>True when Claude is specifically posing a question (vs just finishing).</summary>
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()
};
}