Files

208 lines
8.6 KiB
C#
Raw Permalink Normal View History

2026-06-25 09:59:54 +02:00
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()
};
}