Init
This commit is contained in:
207
Models/ClaudeSession.cs
Normal file
207
Models/ClaudeSession.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
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 (0–100) 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()
|
||||
};
|
||||
}
|
||||
40
Models/UsageInfo.cs
Normal file
40
Models/UsageInfo.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace ClaudeOverview.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Subscription usage pulled from the Claude /api/oauth/usage endpoint.
|
||||
/// Percentages are 0–100.
|
||||
/// </summary>
|
||||
public sealed class UsageInfo
|
||||
{
|
||||
/// <summary>Length of the rolling session-limit block (the API's "five_hour" bucket).</summary>
|
||||
public static readonly TimeSpan SessionWindow = TimeSpan.FromHours(5);
|
||||
|
||||
public double SessionPercent { get; set; }
|
||||
public DateTime? SessionResetsAt { get; set; }
|
||||
|
||||
/// <summary>UTC start of the current session-limit window (its reset time minus the 5-hour block).</summary>
|
||||
public DateTime? SessionWindowStartUtc =>
|
||||
SessionResetsAt is { } r ? r.ToUniversalTime() - SessionWindow : null;
|
||||
|
||||
public double WeeklyPercent { get; set; }
|
||||
public DateTime? WeeklyResetsAt { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
public bool Ok => Error is null;
|
||||
|
||||
/// <summary>True when the last fetch was rejected with HTTP 429 (rate limited).</summary>
|
||||
public bool RateLimited { get; set; }
|
||||
|
||||
/// <summary>Server-suggested wait before retrying (from the Retry-After header), if any.</summary>
|
||||
public TimeSpan? RetryAfter { get; set; }
|
||||
|
||||
public static string ResetsIn(DateTime? resetsAtUtc)
|
||||
{
|
||||
if (resetsAtUtc is null) return "";
|
||||
var span = resetsAtUtc.Value.ToUniversalTime() - DateTime.UtcNow;
|
||||
if (span <= TimeSpan.Zero) return "resetting…";
|
||||
if (span < TimeSpan.FromHours(1)) return $"resets in {(int)span.TotalMinutes}m";
|
||||
if (span < TimeSpan.FromDays(1)) return $"resets in {(int)span.TotalHours}h{span.Minutes:00}m";
|
||||
return $"resets in {(int)span.TotalDays}d{span.Hours}h";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user